diff --git a/.github/scripts/publish-bonsai-releases.py b/.github/scripts/publish-bonsai-releases.py new file mode 100755 index 0000000000..a393104e7c --- /dev/null +++ b/.github/scripts/publish-bonsai-releases.py @@ -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() diff --git a/.github/workflows/build_osx.yml b/.github/workflows/build_osx.yml index 5d4832e251..6ccf2e4f98 100644 --- a/.github/workflows/build_osx.yml +++ b/.github/workflows/build_osx.yml @@ -58,7 +58,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 }} diff --git a/.github/workflows/build_pyodide.yml b/.github/workflows/build_pyodide.yml index fa15e442dd..20eade7254 100644 --- a/.github/workflows/build_pyodide.yml +++ b/.github/workflows/build_pyodide.yml @@ -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 }} diff --git a/.github/workflows/build_rocky.yml b/.github/workflows/build_rocky.yml index b1a24e6212..1af2066580 100644 --- a/.github/workflows/build_rocky.yml +++ b/.github/workflows/build_rocky.yml @@ -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 @@ -48,10 +55,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 @@ -59,7 +66,7 @@ jobs: shell: bash run: | set -o pipefail - CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release BUILD_BONSAIVIEWER=ON python3 ./nix/build-all.py -v --diskcleanup --shared 2>&1 | tee build.log + CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release BUILD_BONSAIVIEWER=ON uv run ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log - name: Upload Build Logs if: always() @@ -74,7 +81,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: | diff --git a/.github/workflows/build_rocky_arm.yml b/.github/workflows/build_rocky_arm.yml index a513f953da..9a813f807b 100644 --- a/.github/workflows/build_rocky_arm.yml +++ b/.github/workflows/build_rocky_arm.yml @@ -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 @@ -48,10 +55,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 @@ -59,7 +66,7 @@ jobs: shell: bash run: | set -o pipefail - CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release BUILD_BONSAIVIEWER=ON python3 ./nix/build-all.py -v --diskcleanup --shared 2>&1 | tee build.log + CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release BUILD_BONSAIVIEWER=ON uv run ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log - name: Upload Build Logs if: always() @@ -74,7 +81,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: | diff --git a/.github/workflows/build_win.yml b/.github/workflows/build_win.yml index cccf497dde..6abaccfb41 100644 --- a/.github/workflows/build_win.yml +++ b/.github/workflows/build_win.yml @@ -56,7 +56,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 diff --git a/.github/workflows/ci-ifcopenshell-docker.yml b/.github/workflows/ci-ifcopenshell-docker.yml index 454955e731..fb100481b7 100644 --- a/.github/workflows/ci-ifcopenshell-docker.yml +++ b/.github/workflows/ci-ifcopenshell-docker.yml @@ -35,7 +35,7 @@ jobs: - name: ccache - uses: hendrikmuhs/ccache-action@v1.2.22 + uses: hendrikmuhs/ccache-action@v1.2.23 - name: Build ifcopenshell diff --git a/.github/workflows/ci-lint.yaml b/.github/workflows/ci-lint.yaml index 6dc18453ad..677ec10c25 100644 --- a/.github/workflows/ci-lint.yaml +++ b/.github/workflows/ci-lint.yaml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e2b15d321..2084496669 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install xmlschema xsdata numpy lxml pytest isodate lark networkx tabulate python-dateutil shapely + pip install xmlschema xsdata numpy lxml pytest isodate lark networkx tabulate python-dateutil shapely pyparsing pip install src/bcf --no-deps pip install pytest-xdist==3.8.0 @@ -79,7 +79,7 @@ jobs: 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 }} diff --git a/.github/workflows/publish-bonsai-releases.yml b/.github/workflows/publish-bonsai-releases.yml new file mode 100644 index 0000000000..6d423deadf --- /dev/null +++ b/.github/workflows/publish-bonsai-releases.yml @@ -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 }} diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt index 85b6c37eb1..a18862a234 100644 --- a/cmake/CMakeLists.txt +++ b/cmake/CMakeLists.txt @@ -269,6 +269,14 @@ if (WITH_ROCKSDB) endif() message(STATUS "RocksDB: found at '${RocksDB_DIR}'.") + # See https://github.com/facebook/rocksdb/issues/981. + if(TARGET RocksDB::rocksdb) + set(IFCOPENSHELL_ROCKSDB_TARGET RocksDB::rocksdb) + elseif(TARGET RocksDB::rocksdb-shared) + set(IFCOPENSHELL_ROCKSDB_TARGET 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 diff --git a/nix/build-all.py b/nix/build-all.py index 57c1f33fe5..c602d9081b 100644 --- a/nix/build-all.py +++ b/nix/build-all.py @@ -1,4 +1,6 @@ #!/usr/bin/python +# /// script +# /// ############################################################################### # # # This file is part of IfcOpenShell. # @@ -123,16 +125,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() diff --git a/nix/cache_dependencies.py b/nix/cache_dependencies.py index 3d115764b4..7d231779ee 100644 --- a/nix/cache_dependencies.py +++ b/nix/cache_dependencies.py @@ -1,3 +1,5 @@ +# /// script +# /// """ Cache built dependencies for builds. diff --git a/pyodide/build_pyodide.sh b/pyodide/build_pyodide.sh index 853aea60ea..f8d8538767 100755 --- a/pyodide/build_pyodide.sh +++ b/pyodide/build_pyodide.sh @@ -22,7 +22,8 @@ uv run pyodide xbuildenv install "${PYODIDE_VERSION}" uv run pyodide xbuildenv install-emscripten EMSDK_ROOT="${PYODIDE_XBUILDENV}/emsdk" -source "${EMSDK_ROOT}/emsdk_env.sh" +[ -f "${EMSDK_ROOT}/emsdk_env.sh" ] && source "${EMSDK_ROOT}/emsdk_env.sh" +[ -f "${EMSDK_ROOT}/../../emsdk_env.sh" ] && source "${EMSDK_ROOT}/../../emsdk_env.sh" which emcc emcc --version diff --git a/pyproject.toml b/pyproject.toml index eb6b4620d7..37aed2dded 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/bonsai/Makefile b/src/bonsai/Makefile index bd7bbbf597..6fc51c463a 100644 --- a/src/bonsai/Makefile +++ b/src/bonsai/Makefile @@ -17,8 +17,8 @@ # along with Bonsai. If not, see . SHELL := sh -PYTHON:=python3.11 -PIP:=pip3.11 +PYTHON:=python3 +PIP:=pip3 PATCH:=patch SED:=sed -i VENV_ACTIVATE:=bin/activate @@ -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 diff --git a/src/bonsai/bonsai/bim/__init__.py b/src/bonsai/bonsai/bim/__init__.py index 4302739aab..fab7646162 100644 --- a/src/bonsai/bonsai/bim/__init__.py +++ b/src/bonsai/bonsai/bim/__init__.py @@ -15,6 +15,8 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# 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 @@ -157,9 +159,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 +267,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 +326,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 diff --git a/src/bonsai/bonsai/bim/data/fonts/LICENSE b/src/bonsai/bonsai/bim/data/fonts/LICENSE new file mode 100644 index 0000000000..4dc4bdd093 --- /dev/null +++ b/src/bonsai/bonsai/bim/data/fonts/LICENSE @@ -0,0 +1,96 @@ +Copyright (c) 2011-2012, Nikita Volchenkov (), +with Reserved Font Name OpenGost Type B. + +Copyright (c) 2012, Valek Filippov (). + +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. diff --git a/src/bonsai/bonsai/bim/decorator_cache.py b/src/bonsai/bonsai/bim/decorator_cache.py new file mode 100644 index 0000000000..118cb33017 --- /dev/null +++ b/src/bonsai/bonsai/bim/decorator_cache.py @@ -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 . +# +# 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 diff --git a/src/bonsai/bonsai/bim/handler.py b/src/bonsai/bonsai/bim/handler.py index e11eb07ce8..dc4af08813 100644 --- a/src/bonsai/bonsai/bim/handler.py +++ b/src/bonsai/bonsai/bim/handler.py @@ -15,11 +15,12 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# 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,30 @@ 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, SlabDirectionDecorator, WallAxisDecorator, + WallFilletPreviewDecorator, ) +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 +123,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 +142,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 "" 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 "" 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 +238,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 +435,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 +449,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 +489,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 +513,57 @@ def load_post(scene): NestDecorator.uninstall() WallAxisDecorator.uninstall() SlabDirectionDecorator.uninstall() - if georeference_props.should_visualise: - GeoreferenceDecorator.install(bpy.context) - if aggregate_props.aggregate_decorator: - AggregateDecorator.install(bpy.context) - if nest_props.nest_decorator: - NestDecorator.install(bpy.context) - if model_props.show_wall_axis: - WallAxisDecorator.install(bpy.context) - if model_props.show_slab_direction: - SlabDirectionDecorator.install(bpy.context) - if model_props.show_bounding_box: - BoundingBoxDecorator.install(bpy.context) + WallFilletPreviewDecorator.uninstall() + 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_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() diff --git a/src/bonsai/bonsai/bim/ifc.py b/src/bonsai/bonsai/bim/ifc.py index 5058b29e60..9dc33d651d 100644 --- a/src/bonsai/bonsai/bim/ifc.py +++ b/src/bonsai/bonsai/bim/ifc.py @@ -439,6 +439,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 @@ -452,6 +453,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. diff --git a/src/bonsai/bonsai/bim/import_ifc.py b/src/bonsai/bonsai/bim/import_ifc.py index eb7024b30e..ed73581236 100644 --- a/src/bonsai/bonsai/bim/import_ifc.py +++ b/src/bonsai/bonsai/bim/import_ifc.py @@ -1215,8 +1215,8 @@ 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) def update_linked_aggregates(self): # TODO Remove this after a while. See commit 17d6b8a diff --git a/src/bonsai/bonsai/bim/module/aggregate/prop.py b/src/bonsai/bonsai/bim/module/aggregate/prop.py index 424f2b829f..46123a90c8 100644 --- a/src/bonsai/bonsai/bim/module/aggregate/prop.py +++ b/src/bonsai/bonsai/bim/module/aggregate/prop.py @@ -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 diff --git a/src/bonsai/bonsai/bim/module/attribute/ui.py b/src/bonsai/bonsai/bim/module/attribute/ui.py index 84813e8d1e..934b053d2e 100644 --- a/src/bonsai/bonsai/bim/module/attribute/ui.py +++ b/src/bonsai/bonsai/bim/module/attribute/ui.py @@ -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: diff --git a/src/bonsai/bonsai/bim/module/drawing/__init__.py b/src/bonsai/bonsai/bim/module/drawing/__init__.py index 9f172ce2bb..d4245cb1db 100644 --- a/src/bonsai/bonsai/bim/module/drawing/__init__.py +++ b/src/bonsai/bonsai/bim/module/drawing/__init__.py @@ -15,6 +15,8 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# 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, diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index d350cf80ee..3c03e9db49 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -16,74 +16,16 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# This file was modified with the assistance of an AI coding tool. -""" -Gizmo infrastructure for parametric BIM element editing. +"""Viewport gizmos for parametric BIM element editing. -This module provides a framework for interactive 3D gizmos that allow users to -manipulate parametric properties of BIM elements (doors, windows, stairs) directly -in the viewport. - -Architecture Overview -===================== - -The gizmo system follows a configuration-driven approach where element-specific -gizmo groups (e.g., GizmoDoorEdition) inherit from BaseParametricGizmoGroup and -declare their gizmos via configuration dataclasses: - - class GizmoDoorEdition(bpy.types.GizmoGroup, BaseParametricGizmoGroup): - dimension_gizmo_props = [ - DimensionGizmoConfig("overall_width", axis=(1, 0, 0)), - DimensionGizmoConfig("overall_height", axis=(0, 0, 1)), - ] - -Key Components -============== - -Configuration Classes: - - DimensionGizmoConfig: Configures dimension line gizmos with text display - -Base Gizmo Classes: - - GizmoMovable: Base for draggable gizmos with keyboard input support - - GizmoDimension: Dimension line gizmo with arrows and text labels - - GizmoArrow2D: 2D arrow gizmo for property manipulation - -Mixin Classes: - - BaseParametricGizmoGroup: Provides common setup/update methods for gizmo groups - -Utility Classes: - - GPUStateScope: Context manager for GPU state save/restore - - NumericInputState: Tracks keyboard numeric input during modal operations - -Global State: - - _gizmo_modal_context: Module-level dataclass instance for modal operator communication - (workaround for Blender's ID property limitations) - -Data Flow -========= - -1. User selects a parametric element (door, window, stair) -2. GizmoGroup.poll() checks if gizmos should be shown -3. GizmoGroup.setup() creates gizmos based on configs -4. GizmoGroup.refresh() updates gizmo positions from element properties -5. User interacts with gizmo -> invoke() -> modal() -> exit() -6. Property changes are written back via move_set_cb callbacks -7. Element mesh is regenerated via operators (e.g., bim.finish_editing_door) - -Snapping System -=============== - -The module includes a mesh vertex snapping system: - - build_snap_cache(): Builds KD-tree from nearby object vertices - - snap_to_mesh(): Snaps 3D position to nearest vertex within threshold - - Uses screen-space distance filtering for accurate snapping - -View-Dependent Positioning -========================== - -Dimension gizmos automatically reposition based on camera view direction to avoid -overlapping with geometry. The get_local_view_direction() helper determines if the -camera is viewing from the positive or negative side of each axis. +Feature gizmo groups (one per parametric type) declare their gizmos via +``DimensionGizmoConfig`` and inherit shared setup / refresh / snapping +machinery from ``BaseParametricGizmoGroup``. Single-click icons bind to +operators via ``target_set_operator``; drag handles inherit modal state +from ``GizmoMovable``. """ __all__ = [ # noqa: RUF022 (unsorted `__all__`) @@ -93,7 +35,7 @@ __all__ = [ # noqa: RUF022 (unsorted `__all__`) "CoordinateSpace", "ModalState", "DimensionGizmoConfig", - "DimensionDrawConfig", + "SwingArcConfig", "ViewDirection", "GizmoModalContext", "get_modal_context", @@ -112,20 +54,24 @@ __all__ = [ # noqa: RUF022 (unsorted `__all__`) "create_circle_arc", "BIM_OT_gizmo_value_input", "GizmoMovable", - "GizmoLock", + "GizmoLockOpen", + "GizmoLockClosed", "GizmoArc", "GizmoPen", "GizmoValidate", "GizmoCancel", "GizmoPlus", "GizmoMinus", + "GizmoArrayParent", + "GizmoArrayAll", + "GizmoArrayLayerIndicator", "GizmoCycle", + "GizmoMenu", "GizmoArrow", "GizmoArrow2D", "GizmoCone", "GizmoDimension", "DimensionRenderer", - "CycleTypeMixin", "BaseParametricGizmoGroup", "UglyDotGizmo", "ExtrusionGuidesGizmo", @@ -136,11 +82,12 @@ import math from collections.abc import Callable, Iterator from dataclasses import dataclass from enum import Enum -from typing import Any, Literal, Protocol, get_args, runtime_checkable +from typing import Any, ClassVar, Literal, Optional, Protocol, runtime_checkable import blf import bpy import gpu +import ifcopenshell.util.element import numpy as np from bpy import types from bpy_extras import view3d_utils @@ -179,6 +126,24 @@ CONE_SEGMENTS = 16 ARC_SEGMENTS = 24 ARC_LINE_WIDTH = 0.015 +# Door-swing arc: start a couple of degrees off the jamb so the arc tip stays +# visible; full quarter-turn for the standard 90-degree swing. +DOOR_SWING_ANGLE_MIN = 2.0 +DOOR_SWING_ANGLE_MAX = 90.0 + +# Default scale factor for billboarded icons (Blender-unit visual size). +DEFAULT_BILLBOARD_SCALE = 0.5 + +# Shared gizmo color constants. Re-exported as class attributes on +# BaseParametricGizmoGroup so callers can use either ``self.COLOR_GREEN`` +# from inside a gizmo group or the module-level constant from a class body +# (e.g. IconSlot declarations) without a forward-reference issue. Match +# Blender's axis convention: X=red, Y=green, Z=blue. +COLOR_RED = (1.0, 0.2, 0.2) +COLOR_GREEN = (0.1, 0.8, 0.1) +COLOR_BLUE = (0.3, 0.3, 1.0) +COLOR_NEUTRAL = (1.0, 1.0, 1.0) + PRECISION_MODE_MULTIPLIER = 0.1 RAY_CAST_DISTANCE = 1000 @@ -194,6 +159,65 @@ _SPECIAL = {"=", " "} # Formula prefix, spaces NUMERIC_INPUT_CHARS = _DIGITS | _OPERATORS | _METRIC_UNITS | _IMPERIAL_UNITS | _SPECIAL +_BONSAI_TRANSFORM_MACROS = frozenset( + { + # Bonsai overrides Blender's default move/duplicate keymaps with + # macros that wrap TRANSFORM_OT_translate. While a macro is the outer + # modal entry, the inner TRANSFORM_OT_translate does not surface in + # window.modal_operators — the macro's own idname does. The + # ``BIM_OT_`` prefix is what Blender returns from ``bl_idname`` at + # runtime (the class declaration uses the dotted ``bim.`` form). + "BIM_OT_override_move_macro", # G key + "BIM_OT_override_object_duplicate_move_macro", # Shift+D + "BIM_OT_override_object_duplicate_move_linked_macro", # Alt+D + "BIM_OT_object_duplicate_move_linked_aggregate_macro", # Ctrl+Shift+D + } +) + + +def _is_transform_modal_active(context) -> bool: + """True iff a Blender transform modal (G/R/S and siblings, including + Bonsai's macro overrides) is currently driving per-frame ``matrix_world`` + updates. Reads ``window.modal_operators`` — the Blender 4.2+ collection of + running modal operators. Parametric gizmo groups gate poll + draw_prepare + on this so they hide for the duration of the drag instead of sliding + off-cursor as the matrix updates each frame.""" + window = getattr(context, "window", None) + if window is None: + return False + modal_ops = getattr(window, "modal_operators", None) + if not modal_ops: + return False + for op in modal_ops: + idname = op.bl_idname + if idname.startswith("TRANSFORM_OT_") or idname in _BONSAI_TRANSFORM_MACROS: + return True + return False + + +def _hide_all_non_modal_gizmos(group) -> None: + """Set ``hide = True`` on every gizmo in ``group`` whose own ``is_modal`` + is False. Used by parametric ``draw_prepare`` to suppress visible + re-positioning while a transform modal is dragging ``matrix_world``.""" + for gz in group.gizmos: + if not getattr(gz, "is_modal", False): + gz.hide = True + + +def apply_transform_modal_draw_gate(group, context) -> bool: + """Combined gate for ``draw_prepare`` overrides: hide non-modal gizmos and + return ``True`` when a Blender transform modal is dragging matrix_world. + + Returns ``False`` when no transform modal is active so callers can fall + through to their normal positioning logic. ``True`` means the caller must + early-return without touching matrix_basis — the hidden gizmos will be + re-shown on the next idle frame once the modal exits.""" + if not _is_transform_modal_active(context): + return False + _hide_all_non_modal_gizmos(group) + return True + + class GizmoColor(Enum): """Color identifiers for dimension gizmos. @@ -307,9 +331,9 @@ class ModalState(Enum): class GizmoModalContext: """Typed context for modal gizmo operations. - This replaces the untyped dict pattern for passing state between gizmos - and the BIM_OT_gizmo_value_input modal operator. Blender ID properties - don't support function callbacks, so we use this module-level instance. + Passes state between a gizmo and the BIM_OT_gizmo_value_input modal operator. + Blender ID properties cannot carry function callbacks, so a module-level + instance carries them out-of-band. Attributes: move_set_cb: Callback to set the property value @@ -468,9 +492,8 @@ class GPUStateScope: class DimensionTextRenderer: """Handles text rendering for dimension gizmos. - Extracted from GizmoDimension to follow Single Responsibility Principle. - This class manages all text drawing operations including value text, - property tooltips, and text backgrounds. + Manages text drawing operations including value text, property + tooltips, and text backgrounds. Usage: renderer = DimensionTextRenderer.get_instance() @@ -511,6 +534,7 @@ class DimensionTextRenderer: color: tuple[float, float, float], offset_sign: int = 1, alignment: TextAlignment | str = TextAlignment.CENTER, + display_text: str | None = None, ) -> None: """Draw formatted dimension value text at the given screen position. @@ -522,15 +546,20 @@ class DimensionTextRenderer: color: Text color (r, g, b) offset_sign: 1 for above/right, -1 for below/left alignment: TextAlignment enum value + display_text: Pre-formatted label. If provided, used verbatim instead of + formatting `value`. """ # Normalize string to enum for comparison if isinstance(alignment, str): alignment = TextAlignment(alignment) - is_negative = value < 0 - text = tool.Unit.format_distance(abs(value)) - if is_negative: - text = "-" + text + if display_text is not None: + text = display_text + else: + is_negative = value < 0 + text = tool.Unit.format_distance(abs(value)) + if is_negative: + text = "-" + text font_id = 0 font_size = tool.Blender.scale_font_size(self.VALUE_FONT_SIZE) @@ -618,50 +647,6 @@ class DimensionTextRenderer: batch.draw(shader) -@dataclass(slots=True, frozen=True) -class DimensionDrawConfig: - """Immutable configuration for drawing a dimension line. - - Groups the many parameters needed by DimensionRenderer.draw() into a - single configuration object, improving readability and maintainability. - - Attributes: - start_world: World-space start position - end_world: World-space end position - axis_world: Normalized axis direction in world space - dimension_length: Length of the dimension (for drawing the line) - color: Base color (r, g, b) - alpha: Base alpha (0.0 to 1.0) - is_highlight: Whether gizmo is highlighted/hovered - highlight_color: Highlight color (r, g, b) - highlight_alpha: Highlight alpha - show_start_arrow: Whether to show arrow at start - show_end_arrow: Whether to show arrow at end - show_extension_lines: Whether to show extension lines - text_offset_sign: 1 for above/right, -1 for below/left - text_alignment: TextAlignment value for text positioning along line - prop_name: Property name for tooltip (shown when highlighted) - display_value: Value to display as text (can be negative); uses dimension_length if None - """ - - start_world: Vector - end_world: Vector - axis_world: Vector - dimension_length: float - color: tuple[float, float, float] = (1.0, 1.0, 1.0) - alpha: float = 1.0 - is_highlight: bool = False - highlight_color: tuple[float, float, float] = (1.0, 1.0, 0.5) - highlight_alpha: float = 1.0 - show_start_arrow: bool = False - show_end_arrow: bool = True - show_extension_lines: bool = True - text_offset_sign: Literal[-1, 1] = 1 - text_alignment: TextAlignment = TextAlignment.CENTER - prop_name: str | None = None - display_value: float | None = None - - @dataclass(slots=True, frozen=True) class ViewDirection: """Immutable representation of camera view direction relative to an element's local space. @@ -730,20 +715,31 @@ class ViewDirection: ) +# Eight unit-length directions for the multi-pass outline shared by every +# icon-class gizmo and by ``DimensionRenderer``'s arrowhead halo. The +# silhouette is rendered once per direction, offset by an outline width +# along that direction; the union approximates a circular dilation — +# a uniform halo on every side. Cardinals are length 1; diagonals use +# sqrt(0.5) components so every direction is at the same Euclidean +# distance from the origin. Uniform scaling around the local origin +# can't replace this: for asymmetric / multi-part geometry it just pushes +# parts further from the origin, which reads as a directional shift +# rather than an outline. +_OUTLINE_DIRECTIONS_8 = ( + (1.0, 0.0), + (-1.0, 0.0), + (0.0, 1.0), + (0.0, -1.0), + (0.7071067811865476, 0.7071067811865476), + (-0.7071067811865476, 0.7071067811865476), + (0.7071067811865476, -0.7071067811865476), + (-0.7071067811865476, -0.7071067811865476), +) + + class DimensionRenderer: - """Handles rendering of dimension line graphics. - - Extracted from GizmoDimension to follow Single Responsibility Principle. - This class manages all dimension drawing operations including lines, - arrows, and extension lines in screen space. - - Usage: - renderer = DimensionRenderer.get_instance() - config = DimensionDrawConfig(start_world, end_world, axis_world, length, color) - renderer.draw(context, config) - # Or use legacy method signature: - renderer.draw(context, start_world, end_world, ...) - """ + """Singleton renderer for dimension line graphics. Draws the dimension + line, end arrows, and extension lines in screen space.""" _instance: "DimensionRenderer | None" = None _line_shader = None @@ -754,6 +750,15 @@ class DimensionRenderer: EXTENSION_LENGTH = 4 LINE_WIDTH = 2.0 MIN_PIXELS_FOR_DETAILS = 35 + # Outline underlay so the dimension stays legible against same-color + # backgrounds (white line on white wall). The line uses a single wider + # dark pass (one extra pixel on each side); the arrowheads use the same + # 8-direction halo technique as icon-class gizmos because a uniform + # widening of a triangle is shape-dependent, not a uniform halo. + OUTLINE_LINE_WIDTH_INCREASE = 2.0 + OUTLINE_LINE_ALPHA = 0.7 + OUTLINE_ARROW_PX = 1.5 + OUTLINE_ARROW_ALPHA = 0.4 @classmethod def get_instance(cls) -> "DimensionRenderer": @@ -795,6 +800,7 @@ class DimensionRenderer: text_alignment: TextAlignment = TextAlignment.CENTER, prop_name: str | None = None, display_value: float | None = None, + display_text: str | None = None, ) -> None: """Draw complete dimension graphics in screen space. @@ -816,6 +822,8 @@ class DimensionRenderer: text_alignment: TextAlignment enum for text positioning prop_name: Property name for tooltip (shown when highlighted) display_value: Value to display as text (can be negative); uses dimension_length if None + display_text: Pre-formatted label string. If provided, used verbatim instead of + formatting `display_value` via tool.Unit.format_distance. """ if dimension_length < 0: return @@ -906,26 +914,40 @@ class DimensionRenderer: vertices.append(ext_end_bottom) indices.append((idx, idx + 1)) + # Force the main pass fully opaque so the dark outline underlay + # doesn't bleed through and grey out the line/arrows. if is_highlight: - draw_color = (*highlight_color, highlight_alpha) + draw_color = (*highlight_color, 1.0) else: - draw_color = (*color, alpha) + draw_color = (*color, 1.0) with GPUStateScope(depth_test="NONE", blend="ALPHA", ortho_2d=(region.width, region.height)): shader = self._get_line_shader() shader.bind() shader.uniform_float("viewportSize", (region.width, region.height)) - shader.uniform_float("lineWidth", self.LINE_WIDTH) - shader.uniform_float("color", draw_color) line_batch = batch_for_shader(shader, "LINES", {"pos": vertices}, indices=indices) + # Underlay for legibility against same-colour backgrounds. + shader.uniform_float("lineWidth", self.LINE_WIDTH + self.OUTLINE_LINE_WIDTH_INCREASE) + shader.uniform_float("color", (0.0, 0.0, 0.0, self.OUTLINE_LINE_ALPHA)) + line_batch.draw(shader) + shader.uniform_float("lineWidth", self.LINE_WIDTH) + shader.uniform_float("color", draw_color) line_batch.draw(shader) if arrow_triangles: tri_shader = self._get_tri_shader() tri_shader.bind() - tri_shader.uniform_float("color", draw_color) tri_batch = batch_for_shader(tri_shader, "TRIS", {"pos": arrow_triangles}) + # Same eight-direction halo as the icon mixin, in screen-pixel units. + tri_shader.uniform_float("color", (0.0, 0.0, 0.0, self.OUTLINE_ARROW_ALPHA)) + for dx, dy in _OUTLINE_DIRECTIONS_8: + with gpu.matrix.push_pop(): + gpu.matrix.multiply_matrix( + Matrix.Translation((dx * self.OUTLINE_ARROW_PX, dy * self.OUTLINE_ARROW_PX, 0.0)) + ) + tri_batch.draw(tri_shader) + tri_shader.uniform_float("color", draw_color) tri_batch.draw(tri_shader) if length_screen >= self.MIN_PIXELS_FOR_DETAILS: @@ -935,7 +957,14 @@ class DimensionRenderer: ) text_color = highlight_color if is_highlight else color DimensionTextRenderer.get_instance().draw_value_text( - context, center_screen, perpendicular, text_value, text_color, text_offset_sign, text_alignment + context, + center_screen, + perpendicular, + text_value, + text_color, + text_offset_sign, + text_alignment, + display_text, ) if is_highlight and prop_name: @@ -1060,12 +1089,14 @@ class ParametricProps(Protocol): @dataclass(slots=True) -class DimensionGizmoConfig: - """Configuration for a dimension gizmo. +class BaseValueGizmoConfig: + """Shared scaffolding for every parametric value gizmo (dimensions, counts, …). - Used to declaratively configure dimension line gizmos in BaseParametricGizmoGroup subclasses. - This enables a data-driven approach that reduces boilerplate code for setting up - dimension gizmos with consistent behavior. + Holds the attribute binding, axis/placement hints, color, and read/write hooks + that any value-driven gizmo declared on a ``BaseParametricGizmoGroup`` needs. + Continuous-distance specifics (arrows, text alignment, snap scaling) belong on + ``DimensionGizmoConfig``; integer-stepper specifics belong on the future + ``CountGizmoConfig`` sibling. Color and prop_name are auto-derived if not specified: - axis (1,0,0) or (-1,0,0) -> RED @@ -1073,6 +1104,141 @@ class DimensionGizmoConfig: - axis (0,0,1) or (0,0,-1) -> BLUE - prop_name: "attr_name" -> "Attr Name" (underscores to spaces, title case) + Attributes: + attr_name: Property name to bind to (e.g., "overall_width"). Used to generate + the per-gizmo attribute on the gizmo group. + axis: Direction tuple (x, y, z). Determines color if not specified and defines + the drag/orientation direction. Use negative values for reversed directions. + color: Optional override. One of "RED", "GREEN", "BLUE". Auto-derived from axis. + prop_name: Display name for tooltips. Defaults to attr_name with underscores + replaced by spaces and title-cased. + compute_value: Optional function(props) -> value for computed values. + If None, reads directly from getattr(props, attr_name). + apply_value: Optional function(props, value) to apply new values after edit. + If None, uses setattr(props, attr_name, value). + visibility_condition: Optional function(props) -> bool. If returns False, + the gizmo is hidden. Used for conditional gizmos. + matrix_position: Optional function(props) -> Vector for gizmo position. + The returned Vector is the local-space position where the gizmo origin + will be placed. Combined with axis to create the full transformation matrix. + """ + + attr_name: str + axis: GizmoAxis + color: GizmoColor | str | None = None # GizmoColor enum, string ("RED"/"GREEN"/"BLUE"), or None for auto + prop_name: str | None = None + compute_value: Callable[[Any], Any] | None = None + apply_value: Callable[[Any, Any], None] | None = None + visibility_condition: Callable[[Any], bool] | None = None + # Optional: function(props) -> Vector position. + # + # SUBTLE: presence of this callable doubles as a *trigger* in + # ``BaseParametricGizmoGroup.update_dimension_gizmos`` — when set, the + # gizmo's per-frame matrix is composed via ``compose_gizmo_matrix``, + # which calls ``get_axis_rotation_matrix(self.axis)`` to align the + # gizmo's intrinsic +X direction with ``self.axis`` in object-local + # space. When this is None, the framework falls back to + # ``base_matrix = Identity`` (no axis rotation), and the dimension's + # visual line renders along the object's local +X regardless of + # ``self.axis``. If your dimension's axis is not local +X, you MUST + # pass a ``matrix_position`` callable — even ``lambda _props: Vector((0, 0, 0))`` + # is enough to flip the branch. The wall pattern uses + # ``set_dimension_gizmo_position`` for this; the declarative pattern + # uses ``matrix_position`` for the same effect. + matrix_position: Callable[[Any], "Vector"] | None = None + + def __post_init__(self): + # Validate attr_name + if not self.attr_name or not isinstance(self.attr_name, str): + raise ValueError("attr_name must be a non-empty string") + + # Validate axis + if len(self.axis) != 3: + raise ValueError(f"axis must be a 3-tuple, got {len(self.axis)} elements") + if not any(self.axis): + raise ValueError("axis must have at least one non-zero component") + + # Normalize and validate color + if self.color is None: + # Auto-derive from axis direction + self.color = GizmoColor.from_axis(self.axis) + elif isinstance(self.color, str): + # Convert string to enum + try: + self.color = GizmoColor(self.color) + except ValueError: + raise ValueError(f"color must be 'RED', 'GREEN', or 'BLUE', got '{self.color}'") + elif not isinstance(self.color, GizmoColor): + raise ValueError(f"color must be GizmoColor enum, string, or None, got {type(self.color)}") + + # Auto-derive prop_name from attr_name if not specified + if self.prop_name is None: + self.prop_name = self.attr_name.replace("_", " ").title() + + +@dataclass(slots=True) +class CountGizmoConfig(BaseValueGizmoConfig): + """Configuration for an integer-stepper gizmo (drag-snap-to-int handle). + + Renders as a fixed-size bar (no arrows, no extension lines) with the integer + value as text. Built on top of ``BIM_GT_gizmo_dimension`` — the underlying + gizmo type is reused; only the configuration differs (arrows/extension + lines off, fixed visual length, ``move_set_cb`` wrapped to snap-to-int and + clamp to [min_count, max_count]). + + Examples: + # Basic count - simple integer stepper bound to props.count + CountGizmoConfig( + attr_name="count", + axis=(1, 0, 0), + min_count=1, + max_count=999, + ) + + # With keyboard sensitivity tuning - drag 1m → count += 5 + CountGizmoConfig( + attr_name="count", + axis=(1, 0, 0), + delta_scale=5.0, + ) + + See ``BaseValueGizmoConfig`` for the shared attributes (attr_name, axis, + color, prop_name, compute_value, apply_value, visibility_condition, + matrix_position). + + Count-specific attributes: + min_count: Minimum allowed value when dragging (default 1). + max_count: Maximum allowed value when dragging (default 999). + step: Integer step size; drag values round to nearest multiple of step. + delta_scale: Drag-to-count multiplier. Higher = more counts per meter + of drag. Default 2.0 = roughly half a count per metre, tuned so a + short flick covers small counts without overshoot. + count_formatter: Optional function(props, value) -> str for the count + label. If None, falls back to ``str(int(value))``. + """ + + min_count: int = 1 + max_count: int = 999 + step: int = 1 + delta_scale: float = 2.0 + count_formatter: Callable[[Any, int], str] | None = None + + def __post_init__(self): + BaseValueGizmoConfig.__post_init__(self) + if self.min_count > self.max_count: + raise ValueError(f"min_count {self.min_count} must be <= max_count {self.max_count}") + if self.step < 1: + raise ValueError(f"step must be >= 1, got {self.step}") + + +@dataclass(slots=True) +class DimensionGizmoConfig(BaseValueGizmoConfig): + """Configuration for a continuous-float dimension line gizmo. + + Used to declaratively configure dimension line gizmos in BaseParametricGizmoGroup subclasses. + This enables a data-driven approach that reduces boilerplate code for setting up + dimension gizmos with consistent behavior. + Examples: # Basic dimension - uses attr_name to read/write property DimensionGizmoConfig( @@ -1096,37 +1262,32 @@ class DimensionGizmoConfig: visibility_condition=lambda props: props.nosing_length > 0, ) - Attributes: - attr_name: Property name to bind to (e.g., "overall_width"). Used to generate - gizmo attribute name as f"dimension_{attr_name}_gizmo". - axis: Direction tuple (x, y, z) for the dimension line. Determines color if not - specified and defines drag direction. Use negative values for reversed directions. - color: Optional override. One of "RED", "GREEN", "BLUE". Auto-derived from axis. - prop_name: Display name for tooltips. Defaults to attr_name with underscores - replaced by spaces and title-cased. - min_value: Minimum allowed value when dragging (default 0.0). + See ``BaseValueGizmoConfig`` for the shared attributes (attr_name, axis, color, + prop_name, compute_value, apply_value, visibility_condition, matrix_position). + + Dimension-specific attributes: + min_value: Lower bound the default ``attr_name`` setter clamps to + before writing (default 0.0 — the floor for natural non-negative + dimensions like ``wall_thickness``, ``casing_thickness``, + ``overall_width``). Only consulted when ``apply_value`` is None; + when a custom ``apply_value`` is supplied, the callback owns any + bounding (it can pass through, absolutise, or reproject the sign + as needed). invert_delta: If True, reverses the drag direction effect. delta_scale: Multiplier for drag delta (default 1.0). Use <1 for fine control. text_offset_sign: 1 or -1 to position text above/below dimension line. text_alignment: "start", "center", or "end" for text positioning along line. show_start_arrow: Whether to show arrow at start point (default False). show_end_arrow: Whether to show arrow at end point (default True). - compute_value: Optional function(props) -> float for computed dimension values. - If None, reads directly from getattr(props, attr_name). - apply_value: Optional function(props, value) to apply new values after drag. - If None, uses setattr(props, attr_name, value). - visibility_condition: Optional function(props) -> bool. If returns False, - the gizmo is hidden. Used for conditional gizmos. - matrix_position: Optional function(props) -> Vector for gizmo position. - If provided, eliminates need for get_dimension_matrix_{attr_name} method. - The returned Vector is the local-space position where the gizmo origin - will be placed. Combined with axis to create the full transformation matrix. + text_formatter: Optional function(props, value) -> str for the dimension label. + Receives the props bag and the post-`compute_value` display value + (i.e. the same number `apply_value` consumes during drag — for the + wall slope gizmo this is the displacement, NOT the underlying + `x_angle`). The raw underlying attribute is accessible as + `getattr(props, attr_name)`. If None, falls back to the default + `tool.Unit.format_distance(abs(value))` with negative-sign handling. """ - attr_name: str - axis: GizmoAxis - color: GizmoColor | str | None = None # GizmoColor enum, string ("RED"/"GREEN"/"BLUE"), or None for auto - prop_name: str | None = None min_value: float = 0.0 invert_delta: bool = False delta_scale: float = 1.0 @@ -1134,21 +1295,15 @@ class DimensionGizmoConfig: text_alignment: TextAlignment | str = TextAlignment.CENTER show_start_arrow: bool = False show_end_arrow: bool = True - compute_value: Callable[[Any], float] | None = None - apply_value: Callable[[Any, float], None] | None = None - visibility_condition: Callable[[Any], bool] | None = None - matrix_position: Callable[[Any], "Vector"] | None = None # Optional: function(props) -> Vector position + text_formatter: Callable[[Any, float], str] | None = None # Optional: function(props, value) -> label text + schematic_visible_length: float | None = None # Override the schematic group's default tag length for this dim. + # In-place dimensions ignore this — it only affects schematic-group rendering. def __post_init__(self): - # Validate attr_name - if not self.attr_name or not isinstance(self.attr_name, str): - raise ValueError("attr_name must be a non-empty string") - - # Validate axis - if len(self.axis) != 3: - raise ValueError(f"axis must be a 3-tuple, got {len(self.axis)} elements") - if not any(self.axis): - raise ValueError("axis must have at least one non-zero component") + # @dataclass(slots=True) rebinds the class in module namespace, leaving super()'s + # implicit __class__ cell pointing at the pre-decorator class. Call the parent + # __post_init__ directly to avoid the resulting TypeError. + BaseValueGizmoConfig.__post_init__(self) # Normalize and validate text_alignment if isinstance(self.text_alignment, str): @@ -1164,23 +1319,6 @@ class DimensionGizmoConfig: if self.text_offset_sign not in (1, -1): raise ValueError(f"text_offset_sign must be 1 or -1, got {self.text_offset_sign}") - # Normalize and validate color - if self.color is None: - # Auto-derive from axis direction - self.color = GizmoColor.from_axis(self.axis) - elif isinstance(self.color, str): - # Convert string to enum - try: - self.color = GizmoColor(self.color) - except ValueError: - raise ValueError(f"color must be 'RED', 'GREEN', or 'BLUE', got '{self.color}'") - elif not isinstance(self.color, GizmoColor): - raise ValueError(f"color must be GizmoColor enum, string, or None, got {type(self.color)}") - - # Auto-derive prop_name from attr_name if not specified - if self.prop_name is None: - self.prop_name = self.attr_name.replace("_", " ").title() - def __repr__(self) -> str: """Concise representation showing key configuration values.""" parts = [f"attr_name={self.attr_name!r}", f"axis={self.axis}"] @@ -1199,6 +1337,52 @@ class DimensionGizmoConfig: return f"DimensionGizmoConfig({', '.join(parts)})" +@dataclass(slots=True) +class IconActionConfig: + """Declarative config for a single icon-action gizmo (one-shot click, + no value, no drag state). + + ``visibility_condition``: optional ``(obj) -> bool`` predicate hiding + this one icon. ``None`` means always visible while the group is polled.""" + + name: str + icon: str + operator: str + visibility_condition: Callable[[Any], bool] | None = None + + +@dataclass(slots=True) +class SwingArcConfig: + """Declarative config for one swing-arc panel — a pair of ``GizmoArc`` + instances representing a single hinged panel's two possible open sides. + + Each entry produces two gizmos at setup time: + - ``self.gizmo_swing_arc_``: main arc on the active swing side + - ``self.gizmo_swing_arc__flip``: Y-mirror of the main, on the + opposite side of the hinge line + + Both gizmos hide together when ``visibility_condition(props)`` is False. + When visible, each arc's ``matrix_basis`` is: + + Translation(hinge_x(props), hinge_y(props), 0) + @ Scale(panel_width(props), 4) + @ (Scale(-1, X) if x_mirror(props) else Identity) + @ (Scale(-1, Y) if this is the flip arc else Identity) + + The arc geometry (``GizmoArc.tris``) is a unit quarter-arc sweeping + counterclockwise from +X to +Y with its hinge at the origin, so the + transforms above translate the hinge into world position, scale to + panel size, and mirror across the hinge line as needed. + """ + + name: str + visibility_condition: Callable[[Any], bool] + hinge_x: Callable[[Any], float] + hinge_y: Callable[[Any], float] + panel_width: Callable[[Any], float] + x_mirror: Callable[[Any], bool] + + class SnapManager: """Manages snap point visualization and mesh snapping with caching.""" @@ -1366,24 +1550,11 @@ class SnapManager: nearby_objects = [] for obj in mesh_objects: - bbox_corners = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box] - if not bbox_corners: + if not obj.bound_box: continue - - bbox_min = Vector( - ( - min(c.x for c in bbox_corners), - min(c.y for c in bbox_corners), - min(c.z for c in bbox_corners), - ) - ) - bbox_max = Vector( - ( - max(c.x for c in bbox_corners), - max(c.y for c in bbox_corners), - max(c.z for c in bbox_corners), - ) - ) + bbox = tool.Blender.get_object_world_bounding_box(obj) + bbox_min = bbox["min_point"] + bbox_max = bbox["max_point"] closest = Vector( ( @@ -1576,6 +1747,355 @@ def get_billboard_rotation(context: bpy.types.Context) -> Matrix: return rv3d.view_matrix.to_3x3().transposed().to_4x4() +def billboarded_at(world_pos: Vector, billboard_rot: Matrix, scale: float = DEFAULT_BILLBOARD_SCALE) -> Matrix: + """Compose the standard icon matrix_basis: translate to ``world_pos``, billboard to the camera, + then uniformly scale.""" + return Matrix.Translation(world_pos) @ billboard_rot @ Matrix.Scale(scale, 4) + + +def billboarded_along_axis( + world_pos: Vector, + billboard_rot: Matrix, + axis_world: Vector, + scale: float = DEFAULT_BILLBOARD_SCALE, +) -> Matrix: + """Composed matrix_basis like ``billboarded_at`` but with local +X + rotated about the camera-forward axis to align with ``axis_world`` + projected onto the screen plane. + + The gizmo still faces the camera (local +Z stays along camera-forward), + only its in-plane orientation changes. Falls back to plain + ``billboarded_at`` when the axis is near-parallel to the view direction + (no usable screen projection).""" + camera_forward = billboard_rot @ Vector((0.0, 0.0, 1.0)) + projected = axis_world - camera_forward * axis_world.dot(camera_forward) + if projected.length < 1e-4: + return billboarded_at(world_pos, billboard_rot, scale) + projected.normalize() + y_axis = camera_forward.cross(projected).normalized() + rot = Matrix.Identity(4) + rot[0][:3] = (projected.x, y_axis.x, camera_forward.x) + rot[1][:3] = (projected.y, y_axis.y, camera_forward.y) + rot[2][:3] = (projected.z, y_axis.z, camera_forward.z) + return Matrix.Translation(world_pos) @ rot @ Matrix.Scale(scale, 4) + + +def get_screen_up(billboard_rot: Matrix) -> Vector: + """Camera's screen-up direction in world space — local +Y of the billboard + rotation. Use to lift a gizmo above an anchor in a way that stays + perpendicular to the view plane (world +Z collapses to zero on-screen in + top-down view and lands lifted gizmos on top of their anchors).""" + return billboard_rot @ Vector((0.0, 1.0, 0.0)) + + +# Screen-up distance lifted off floor-plane gizmo anchors in plan view. Matches +# the inter-icon stack spacing used by wall-corner stacks so single icons and +# stack bases sit at consistent screen-up positions when multiple groups render +# around the same wall endpoint. +DEFAULT_TOP_DOWN_CLEARANCE = 0.4 + + +def top_down_clearance( + context: bpy.types.Context, + billboard_rot: Matrix, + distance: float = DEFAULT_TOP_DOWN_CLEARANCE, +) -> Vector: + """Screen-up offset that keeps a floor-plane gizmo anchor visible in plan view. + + In a top-down view the world-Z axis projects to ~zero on screen, so any + icon anchored on the floor (wall endpoints, corners, connection points, + the projected 3D cursor) sits directly on the click target it represents. + Adding this offset before ``billboarded_at`` shifts the icon along the + camera's up axis without changing the operator's world-space target. + + Returns a zero vector outside the top-down cone so callers can apply it + unconditionally.""" + if not tool.Blender.is_view_top_down(context): + return Vector((0.0, 0.0, 0.0)) + return get_screen_up(billboard_rot) * distance + + +# Dead-band on the screen-X delta — prevents flicker when the gizmo sits on the +# element origin. +EXTEND_FLIP_EPSILON = 1e-4 + +# Post-multipliers that mirror a billboarded matrix about its local X / Y axis. +EXTEND_FLIP_MIRROR_X = Matrix.Diagonal(Vector((-1.0, 1.0, 1.0, 1.0))) +EXTEND_FLIP_MIRROR_Y = Matrix.Diagonal(Vector((1.0, -1.0, 1.0, 1.0))) + + +def should_flip_extend_arrow( + gizmo_world: Vector, + reference_world: Vector, + billboard_rot: Matrix, +) -> bool: + """True when ``reference_world`` projects to screen-right of ``gizmo_world`` — + mirror the extend arrow's local X so it points away from the reference in screen space.""" + screen_delta = billboard_rot.transposed() @ (reference_world - gizmo_world) + return screen_delta.x > EXTEND_FLIP_EPSILON + + +def setup_icon_gizmo( + gizmo_group: bpy.types.GizmoGroup, + gizmo_type: str, + color: tuple[float, float, float], + highlight_color: tuple[float, float, float], + operator: str, + alpha: float = 0.8, +) -> bpy.types.Gizmo: + """Create an icon gizmo with the Bonsai defaults (no draw-scale, fixed + alpha, click-to-operator).""" + gizmo = gizmo_group.gizmos.new(gizmo_type) + gizmo.use_draw_scale = False + gizmo.color = color + gizmo.color_highlight = highlight_color + gizmo.alpha = alpha + gizmo.target_set_operator(operator) + return gizmo + + +def get_warning_color_from_prefs(prefs) -> tuple[float, float, float]: + """Hover color for destructive gizmo icons (split, unjoin, delete).""" + return prefs.decorator_color_error[:3] + + +# --- Tris geometry helpers ---------------------------------------------------- +# Shared by the icon ``bpy.types.Gizmo`` subclasses defined later in this module. +# Each gizmo declares a flat ``tris`` tuple of (x, y, z) vertices grouped into +# triangles of 3; these helpers compose tris from primitives so the per-gizmo +# definitions stay small and visually readable. + + +def rect_tris(x0: float, y0: float, x1: float, y1: float) -> tuple[tuple[float, float, float], ...]: + """Two triangles forming an axis-aligned rectangle from ``(x0, y0)`` to ``(x1, y1)``, + in the Z=0 plane (the convention for icon gizmos).""" + return ( + (x0, y0, 0.0), + (x0, y1, 0.0), + (x1, y1, 0.0), + (x0, y0, 0.0), + (x1, y1, 0.0), + (x1, y0, 0.0), + ) + + +def swap_xy_tris( + tris: tuple[tuple[float, float, float], ...], +) -> tuple[tuple[float, float, float], ...]: + """Reflect a ``tris`` tuple across the Y=X diagonal — useful when a "vertical" + sibling of a "horizontal" icon should otherwise be a literal copy.""" + return tuple((y, x, z) for x, y, z in tris) + + +# Module-level GPU caches for StaticTrisGizmoMixin. Batches are keyed by +# concrete subclass (each has its own ``tris``); the shader is a single +# UNIFORM_COLOR instance shared across all icon-class gizmos. Both must be +# cleared on addon unregister + ``load_post`` because GPUBatch / GPUShader +# references hold GPU resources that go stale across blend-file reloads. +_static_tris_batches: dict[type, "gpu.types.GPUBatch"] = {} +_static_tris_shader = None + + +def _get_static_tris_shader(): + global _static_tris_shader + if _static_tris_shader is None: + _static_tris_shader = gpu.shader.from_builtin("UNIFORM_COLOR") + return _static_tris_shader + + +def _get_static_tris_batch(cls): + batch = _static_tris_batches.get(cls) + if batch is None: + batch = batch_for_shader(_get_static_tris_shader(), "TRIS", {"pos": cls.tris}) + _static_tris_batches[cls] = batch + return batch + + +def clear_static_tris_cache() -> None: + """Drops the cached per-class TRIS batches and shader. Wired into addon + teardown + ``load_post`` so GPU resources don't outlive their context.""" + global _static_tris_shader + _static_tris_batches.clear() + _static_tris_shader = None + + +# Single source of truth for icon-class outline defaults. Referenced from +# both ``StaticTrisGizmoMixin`` (class-attribute defaults a concrete gizmo +# can override per-class) and ``draw_tris_with_outline`` (helper called +# from dynamic-tris gizmos that don't inherit the mixin). ``_OUTLINE_DIRECTIONS_8`` +# lives near ``DimensionRenderer`` because both consumers reference it. +_OUTLINE_DEFAULT_WIDTH = 0.03 +_OUTLINE_DEFAULT_ALPHA = 0.4 + + +def _draw_outline_and_body( + shader: "gpu.types.GPUShader", + batch: "gpu.types.GPUBatch", + base_matrix: Matrix, + color: tuple[float, float, float, float], + outline_width: float, + outline_alpha: float, +) -> None: + """Renders 8 outline passes (semi-transparent black, offset by + ``outline_width`` in the cardinal + diagonal unit directions) followed + by the body pass at ``color``, wrapped in ALPHA blend state. + + Caller must bind the shader and configure any sampler / texture + uniforms before calling. The ``color`` uniform is set internally for + each pass — caller's ``color`` uniform is overwritten.""" + with GPUStateScope(blend="ALPHA"): + if outline_alpha > 0.0 and outline_width > 0.0: + shader.uniform_float("color", (0.0, 0.0, 0.0, outline_alpha)) + for dx, dy in _OUTLINE_DIRECTIONS_8: + offset_matrix = base_matrix @ Matrix.Translation((dx * outline_width, dy * outline_width, 0.0)) + with gpu.matrix.push_pop(): + gpu.matrix.multiply_matrix(offset_matrix) + batch.draw(shader) + shader.uniform_float("color", color) + with gpu.matrix.push_pop(): + gpu.matrix.multiply_matrix(base_matrix) + batch.draw(shader) + + +def draw_tris_with_outline( + batch: "gpu.types.GPUBatch", + base_matrix: Matrix, + color: tuple[float, float, float, float], + outline_width: float = _OUTLINE_DEFAULT_WIDTH, + outline_alpha: float = _OUTLINE_DEFAULT_ALPHA, +) -> None: + """Renders ``batch`` as an opaque tris body with an 8-way dark halo behind. + + Shared between StaticTrisGizmoMixin and custom-draw gizmos with dynamic + tris. The caller supplies the per-frame matrix and the icon color; this + routine handles shader binding, the eight outline passes, the body + pass, and the surrounding GPU blend state.""" + shader = _get_static_tris_shader() + shader.bind() + _draw_outline_and_body(shader, batch, base_matrix, color, outline_width, outline_alpha) + + +class StaticTrisGizmoMixin: + """Mixin for gizmos drawing a static class-level ``tris`` tuple. + + Renders the icon nine times: eight outline passes (the silhouette in + semi-transparent black, offset by ``outline_width`` in eight unit-length + directions), then the icon itself at its normal color. The union of the + eight offset silhouettes approximates a circular dilation of the icon, + producing a uniform dark halo on every side — keeps glyphs legible on + any background (white walls, white mesh, dark theme, dark mesh). + Disable per-class with ``outline_alpha = 0.0`` or ``outline_width = 0``.""" + + # Outline ring width in local tris coordinates. The existing tris span + # roughly ±0.3 to ±0.45 in local XY; 0.03 produces a ~6–10% halo on + # every side, readable on any background without crowding the glyph. + outline_width: float = _OUTLINE_DEFAULT_WIDTH + # Per-pass alpha. Eight overlapping passes accumulate where they meet, + # so 0.4 per pass produces a near-opaque inner ring (~0.98 cumulative) + # and a clearly visible outer fade (single-pass 0.4 at the dilation edge). + outline_alpha: float = _OUTLINE_DEFAULT_ALPHA + # When True, hit shape is the glyph's 2D bounding box (plus ``outline_width`` + # padding) — clickable surface matches the visible tile, no dead zones. + # Subclasses used in tight stacks (where adjacent icons sit closer than the + # bbox extent) should set this False so each icon's hit area stays inside + # its glyph and adjacent icons don't steal each other's clicks. + hit_uses_bbox: bool = True + + def setup(self) -> None: + if self.hit_uses_bbox: + xs = [v[0] for v in self.tris] + ys = [v[1] for v in self.tris] + pad = self.outline_width + hit_tris = rect_tris(min(xs) - pad, min(ys) - pad, max(xs) + pad, max(ys) + pad) + else: + hit_tris = self.tris + self.custom_shape = self.new_custom_shape("TRIS", hit_tris) + + def draw(self, context: bpy.types.Context) -> None: + # Icon body is forced fully opaque: any ``self.alpha`` < 1.0 would + # let the dark outline behind bleed through and grey out the glyph. + # Hover-vs-default is conveyed by RGB only. + if self.is_highlight: + color = (*self.color_highlight, 1.0) + else: + color = (*self.color, 1.0) + draw_tris_with_outline( + _get_static_tris_batch(type(self)), + self.matrix_basis @ self.matrix_offset, + color, + self.outline_width, + self.outline_alpha, + ) + + def draw_select(self, context: bpy.types.Context, select_id: int) -> None: + self.draw_custom_shape(self.custom_shape, select_id=select_id) + + +# Unit quad in the Z=0 plane — same local space as icon-class ``tris`` tuples, +# so ``matrix_basis`` / ``scale_basis`` position it identically. +_TEXTURED_QUAD_POSITIONS = ( + (-0.5, -0.5, 0.0), + (0.5, -0.5, 0.0), + (0.5, 0.5, 0.0), + (-0.5, 0.5, 0.0), +) +_TEXTURED_QUAD_TEX_COORDS = ( + (0.0, 0.0), + (1.0, 0.0), + (1.0, 1.0), + (0.0, 1.0), +) + + +class TexturedQuadGizmoMixin(StaticTrisGizmoMixin): + """Renders a billboarded textured quad from ``bim/data/icons/.png``. + + Inherits ``StaticTrisGizmoMixin`` on purpose: ``draw_select`` and the + tris fallback stay available. Any texture failure (missing PNG, GPU + init error, mid-reload race) falls through to ``super().draw`` so the + gizmo never disappears. ``outline_scale`` / ``outline_alpha`` are + inherited from the parent and apply identically — IMAGE_COLOR multiplies + the sampled texel by the uniform color, so a black-tinted scaled-up pass + produces a dark halo around the PNG silhouette.""" + + icon_name: str = "" + + def setup(self) -> None: + super().setup() + from bonsai.bim.module.drawing import gizmo_textures + + self._quad_batch = batch_for_shader( + gizmo_textures.get_shader(), + "TRI_FAN", + {"pos": _TEXTURED_QUAD_POSITIONS, "texCoord": _TEXTURED_QUAD_TEX_COORDS}, + ) + + def draw(self, context: bpy.types.Context) -> None: + from bonsai.bim.module.drawing import gizmo_textures + + texture = gizmo_textures.get_icon_texture(self.icon_name) + if texture is None: + super().draw(context) + return + shader = gizmo_textures.get_shader() + # Icon body forced fully opaque so the dark outline behind doesn't + # bleed through the texture and grey out the glyph. + if self.is_highlight: + color = (*self.color_highlight, 1.0) + else: + color = (*self.color, 1.0) + shader.bind() + shader.uniform_sampler("image", texture) + _draw_outline_and_body( + shader, + self._quad_batch, + self.matrix_basis @ self.matrix_offset, + color, + self.outline_width, + self.outline_alpha, + ) + + def get_camera_direction(context: bpy.types.Context, position: Vector) -> Vector | None: """Get normalized direction from position towards camera.""" rv3d = context.region_data @@ -1947,18 +2467,14 @@ class OffsetHandle: return {"CANCELLED"} delta = coordz - self.init_coordz if "PRECISE" in tweak: - delta /= 10.0 + delta *= PRECISION_MODE_MULTIPLIER value = max(0, self.init_value + delta) value *= self.scale_value - # ctx.area.header_text_set(f"coords: {self.init_coordz} - {coordz}, delta: {delta}, value: {value}") ctx.area.header_text_set(f"Depth: {value}") self.target_set_value("offset", value) return {"RUNNING_MODAL"} def project_mouse(self, ctx, event): - """Projecting mouse coords to local axis Z""" - # logic from source/blender/editors/gizmo_library/gizmo_types/arrow3d_gizmo.c:gizmo_arrow_modal - mouse = Vector((event.mouse_region_x, event.mouse_region_y)) region = ctx.region region3d = ctx.region_data @@ -2026,7 +2542,6 @@ class ExtrusionGuidesGizmo(CustomGizmo, types.Gizmo): __slots__ = ("scale_value", "custom_shape") def setup(self): - """setup `custom_shape`""" shader_wrapper = ExtrusionGuidesShader() verts = [Vector((0, 0, 0)), Vector((0, 0, 1))] verts, edges = shader_wrapper.process_geometry(verts) @@ -2097,7 +2612,6 @@ class ExtrusionWidget(types.GizmoGroup): gz.scale_value = scale_value def refresh(self, context: bpy.types.Context) -> None: - """updating gizmos""" target = context.active_object if not target: return @@ -2106,7 +2620,6 @@ class ExtrusionWidget(types.GizmoGroup): self.guides.matrix_basis = basis def update(self, context: bpy.types.Context) -> None: - """updating object""" bpy.ops.bim.update_parametric_representation() target = context.active_object if not target: @@ -2415,6 +2928,16 @@ class GizmoMovable(bpy.types.Gizmo): # Threshold in pixels for considering mouse movement as a drag DRAG_THRESHOLD = 5 + def _get_triangles(self) -> tuple[tuple[float, float, float], ...]: + """Subclasses must return TRIS-mode geometry for the custom shape.""" + raise NotImplementedError(f"{type(self).__name__} must define _get_triangles()") + + def setup(self) -> None: + self.custom_shape = self.new_custom_shape("TRIS", self._get_triangles()) + + def draw_select(self, context: bpy.types.Context, select_id: int) -> None: + self.draw_custom_shape(self.custom_shape, select_id=select_id) + def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set: self.init_value = self.move_get_cb() if self.move_get_cb else 0.0 self.start_location = self.matrix_basis.translation.copy() @@ -2702,172 +3225,379 @@ class GizmoMovable(bpy.types.Gizmo): blf.disable(font_id, blf.SHADOW) -class GizmoLock(bpy.types.Gizmo): - """Lock icon gizmo that switches between closed and open states.""" +LOCK_TRIS_OPEN = ( + (-0.12838619947433472, 1.3143587112426758, 0.0), + (0.025773197412490845, 1.411454677581787, 0.0), + (-0.0144234299659729, 1.541273593902588, 0.0), + (-0.0144234299659729, 1.541273593902588, 0.0), + (0.025773197412490845, 1.411454677581787, 0.0), + (0.20782703161239624, 1.4184625148773193, 0.0), + (0.23792517185211182, 1.5509872436523438, 0.0), + (0.20782703161239624, 1.4184625148773193, 0.0), + (0.3689943850040436, 1.3335046768188477, 0.0), + (0.4613226056098938, 1.433225393295288, 0.0), + (0.3689943850040436, 1.3335046768188477, 0.0), + (0.4660903215408325, 1.1793451309204102, 0.0), + (0.5959094166755676, 1.2195416688919067, 0.0), + (0.4660903215408325, 1.1793451309204102, 0.0), + (0.47309836745262146, 0.997291088104248, 0.0), + (0.6056233048439026, 0.9671931266784668, 0.0), + (0.47309836745262146, 0.997291088104248, 0.0), + (0.3881405293941498, 0.8361238241195679, 0.0), + (-0.48786139488220215, 0.7437955141067505, 0.0), + (0.48786139488220215, 4.5077928945147505e-08, 0.0), + (0.48786139488220215, 0.7437955141067505, 0.0), + (-0.12838619947433472, 1.3143587112426758, 0.0), + (-0.0144234299659729, 1.541273593902588, 0.0), + (-0.22810709476470947, 1.406686782836914, 0.0), + (-0.0144234299659729, 1.541273593902588, 0.0), + (0.20782703161239624, 1.4184625148773193, 0.0), + (0.23792517185211182, 1.5509872436523438, 0.0), + (0.23792517185211182, 1.5509872436523438, 0.0), + (0.3689943850040436, 1.3335046768188477, 0.0), + (0.4613226056098938, 1.433225393295288, 0.0), + (0.4613226056098938, 1.433225393295288, 0.0), + (0.4660903215408325, 1.1793451309204102, 0.0), + (0.5959094166755676, 1.2195416688919067, 0.0), + (0.5959094166755676, 1.2195416688919067, 0.0), + (0.47309836745262146, 0.997291088104248, 0.0), + (0.6056233048439026, 0.9671931266784668, 0.0), + (0.6056233048439026, 0.9671931266784668, 0.0), + (0.3881405293941498, 0.8361238241195679, 0.0), + (0.48786142468452454, 0.74379563331604, 0.0), + (-0.48786139488220215, 0.7437955141067505, 0.0), + (-0.48786139488220215, 4.5077928945147505e-08, 0.0), + (0.48786139488220215, 4.5077928945147505e-08, 0.0), +) - bl_idname = "VIEW3D_GT_lock" - - __slots__ = ( - "custom_shape_closed", - "custom_shape_open", - "prop_path", - ) - - tris_closed = ( - (-0.12838619947433472, 1.3143587112426758, 0.0), - (0.025773197412490845, 1.411454677581787, 0.0), - (-0.0144234299659729, 1.541273593902588, 0.0), - (-0.0144234299659729, 1.541273593902588, 0.0), - (0.025773197412490845, 1.411454677581787, 0.0), - (0.20782703161239624, 1.4184625148773193, 0.0), - (0.23792517185211182, 1.5509872436523438, 0.0), - (0.20782703161239624, 1.4184625148773193, 0.0), - (0.3689943850040436, 1.3335046768188477, 0.0), - (0.4613226056098938, 1.433225393295288, 0.0), - (0.3689943850040436, 1.3335046768188477, 0.0), - (0.4660903215408325, 1.1793451309204102, 0.0), - (0.5959094166755676, 1.2195416688919067, 0.0), - (0.4660903215408325, 1.1793451309204102, 0.0), - (0.47309836745262146, 0.997291088104248, 0.0), - (0.6056233048439026, 0.9671931266784668, 0.0), - (0.47309836745262146, 0.997291088104248, 0.0), - (0.3881405293941498, 0.8361238241195679, 0.0), - (-0.48786139488220215, 0.7437955141067505, 0.0), - (0.48786139488220215, 4.5077928945147505e-08, 0.0), - (0.48786139488220215, 0.7437955141067505, 0.0), - (-0.12838619947433472, 1.3143587112426758, 0.0), - (-0.0144234299659729, 1.541273593902588, 0.0), - (-0.22810709476470947, 1.406686782836914, 0.0), - (-0.0144234299659729, 1.541273593902588, 0.0), - (0.20782703161239624, 1.4184625148773193, 0.0), - (0.23792517185211182, 1.5509872436523438, 0.0), - (0.23792517185211182, 1.5509872436523438, 0.0), - (0.3689943850040436, 1.3335046768188477, 0.0), - (0.4613226056098938, 1.433225393295288, 0.0), - (0.4613226056098938, 1.433225393295288, 0.0), - (0.4660903215408325, 1.1793451309204102, 0.0), - (0.5959094166755676, 1.2195416688919067, 0.0), - (0.5959094166755676, 1.2195416688919067, 0.0), - (0.47309836745262146, 0.997291088104248, 0.0), - (0.6056233048439026, 0.9671931266784668, 0.0), - (0.6056233048439026, 0.9671931266784668, 0.0), - (0.3881405293941498, 0.8361238241195679, 0.0), - (0.48786142468452454, 0.74379563331604, 0.0), - (-0.48786139488220215, 0.7437955141067505, 0.0), - (-0.48786139488220215, 4.5077928945147505e-08, 0.0), - (0.48786139488220215, 4.5077928945147505e-08, 0.0), - ) - - tris_open = ( - (-0.3519617021083832, 0.7437955141067505, 0.0), - (-0.3048076927661896, 0.9197763204574585, 0.0), - (-0.4225003123283386, 0.9877263307571411, 0.0), - (-0.4225003123283386, 0.9877263307571411, 0.0), - (-0.3048076927661896, 0.9197763204574585, 0.0), - (-0.1759808510541916, 1.0486031770706177, 0.0), - (-0.24393069744110107, 1.1662957668304443, 0.0), - (-0.1759808510541916, 1.0486031770706177, 0.0), - (2.9078805141580233e-08, 1.0957571268081665, 0.0), - (2.9078805141580233e-08, 1.2316569089889526, 0.0), - (2.9078805141580233e-08, 1.0957571268081665, 0.0), - (0.1759808510541916, 1.0486031770706177, 0.0), - (0.243930846452713, 1.1662957668304443, 0.0), - (0.1759808510541916, 1.0486031770706177, 0.0), - (0.30480796098709106, 0.9197763204574585, 0.0), - (0.4225005805492401, 0.9877263307571411, 0.0), - (0.30480796098709106, 0.9197763204574585, 0.0), - (0.35196200013160706, 0.7437955141067505, 0.0), - (-0.48786139488220215, 0.7437955141067505, 0.0), - (0.48786139488220215, 4.5077928945147505e-08, 0.0), - (0.48786139488220215, 0.7437955141067505, 0.0), - (-0.3519617021083832, 0.7437955141067505, 0.0), - (-0.4225003123283386, 0.9877263307571411, 0.0), - (-0.48786139488220215, 0.7437955141067505, 0.0), - (-0.4225003123283386, 0.9877263307571411, 0.0), - (-0.1759808510541916, 1.0486031770706177, 0.0), - (-0.24393069744110107, 1.1662957668304443, 0.0), - (-0.24393069744110107, 1.1662957668304443, 0.0), - (2.9078805141580233e-08, 1.0957571268081665, 0.0), - (2.9078805141580233e-08, 1.2316569089889526, 0.0), - (2.9078805141580233e-08, 1.2316569089889526, 0.0), - (0.1759808510541916, 1.0486031770706177, 0.0), - (0.243930846452713, 1.1662957668304443, 0.0), - (0.243930846452713, 1.1662957668304443, 0.0), - (0.30480796098709106, 0.9197763204574585, 0.0), - (0.4225005805492401, 0.9877263307571411, 0.0), - (0.4225005805492401, 0.9877263307571411, 0.0), - (0.35196200013160706, 0.7437955141067505, 0.0), - (0.487861692905426, 0.74379563331604, 0.0), - (-0.48786139488220215, 0.7437955141067505, 0.0), - (-0.48786139488220215, 4.5077928945147505e-08, 0.0), - (0.48786139488220215, 4.5077928945147505e-08, 0.0), - ) - - def get_custom_shape(self, context: bpy.types.Context) -> object: - """Get the appropriate custom shape based on lock state.""" - obj = context.active_object - if not obj: - return self.custom_shape_closed - - try: - is_open = obj.path_resolve(self.prop_path) - return self.custom_shape_open if is_open else self.custom_shape_closed - except (ValueError, KeyError, AttributeError): - return self.custom_shape_closed - - def setup(self) -> None: - self.custom_shape_closed = self.new_custom_shape("TRIS", self.tris_closed) - self.custom_shape_open = self.new_custom_shape("TRIS", self.tris_open) - - def draw(self, context: bpy.types.Context) -> None: - self.draw_custom_shape(self.get_custom_shape(context)) - - def draw_select(self, context: bpy.types.Context, select_id: int) -> None: - self.draw_custom_shape(self.get_custom_shape(context), select_id=select_id) +LOCK_TRIS_CLOSED = ( + (-0.3519617021083832, 0.7437955141067505, 0.0), + (-0.3048076927661896, 0.9197763204574585, 0.0), + (-0.4225003123283386, 0.9877263307571411, 0.0), + (-0.4225003123283386, 0.9877263307571411, 0.0), + (-0.3048076927661896, 0.9197763204574585, 0.0), + (-0.1759808510541916, 1.0486031770706177, 0.0), + (-0.24393069744110107, 1.1662957668304443, 0.0), + (-0.1759808510541916, 1.0486031770706177, 0.0), + (2.9078805141580233e-08, 1.0957571268081665, 0.0), + (2.9078805141580233e-08, 1.2316569089889526, 0.0), + (2.9078805141580233e-08, 1.0957571268081665, 0.0), + (0.1759808510541916, 1.0486031770706177, 0.0), + (0.243930846452713, 1.1662957668304443, 0.0), + (0.1759808510541916, 1.0486031770706177, 0.0), + (0.30480796098709106, 0.9197763204574585, 0.0), + (0.4225005805492401, 0.9877263307571411, 0.0), + (0.30480796098709106, 0.9197763204574585, 0.0), + (0.35196200013160706, 0.7437955141067505, 0.0), + (-0.48786139488220215, 0.7437955141067505, 0.0), + (0.48786139488220215, 4.5077928945147505e-08, 0.0), + (0.48786139488220215, 0.7437955141067505, 0.0), + (-0.3519617021083832, 0.7437955141067505, 0.0), + (-0.4225003123283386, 0.9877263307571411, 0.0), + (-0.48786139488220215, 0.7437955141067505, 0.0), + (-0.4225003123283386, 0.9877263307571411, 0.0), + (-0.1759808510541916, 1.0486031770706177, 0.0), + (-0.24393069744110107, 1.1662957668304443, 0.0), + (-0.24393069744110107, 1.1662957668304443, 0.0), + (2.9078805141580233e-08, 1.0957571268081665, 0.0), + (2.9078805141580233e-08, 1.2316569089889526, 0.0), + (2.9078805141580233e-08, 1.2316569089889526, 0.0), + (0.1759808510541916, 1.0486031770706177, 0.0), + (0.243930846452713, 1.1662957668304443, 0.0), + (0.243930846452713, 1.1662957668304443, 0.0), + (0.30480796098709106, 0.9197763204574585, 0.0), + (0.4225005805492401, 0.9877263307571411, 0.0), + (0.4225005805492401, 0.9877263307571411, 0.0), + (0.35196200013160706, 0.7437955141067505, 0.0), + (0.487861692905426, 0.74379563331604, 0.0), + (-0.48786139488220215, 0.7437955141067505, 0.0), + (-0.48786139488220215, 4.5077928945147505e-08, 0.0), + (0.48786139488220215, 4.5077928945147505e-08, 0.0), +) -class GizmoArc(bpy.types.Gizmo): - """Arc gizmo for door swing visualization.""" +class GizmoLockOpen(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Static open-padlock glyph.""" + + bl_idname = "VIEW3D_GT_lock_open" + __slots__ = ("custom_shape",) + tris = LOCK_TRIS_OPEN + + +class GizmoLockClosed(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Static closed-padlock glyph.""" + + bl_idname = "VIEW3D_GT_lock_closed" + __slots__ = ("custom_shape",) + tris = LOCK_TRIS_CLOSED + + +ARC_TRIS_DEFAULT = create_circle_arc( + radius=1.0, direction="LEFT", angle_min=DOOR_SWING_ANGLE_MIN, angle_max=DOOR_SWING_ANGLE_MAX +) + + +class GizmoArc(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Static quarter-arc glyph for swing visualisation. + + Consumers needing the mirrored (RIGHT) visual apply a flip-X matrix to + ``matrix_basis``. ``outline_alpha = 0.0`` suppresses the inherited 8-pass + dark halo: an open curve has no enclosed silhouette for the dilation to + ring, so the offset passes read as ghost arcs rather than a uniform + outline. The arc's own cross-section thickness keeps it legible without + the halo.""" bl_idname = "VIEW3D_GT_arc" + __slots__ = ("custom_shape",) + tris = ARC_TRIS_DEFAULT + outline_alpha = 0.0 - __slots__ = ( - "custom_shape_left", - "custom_shape_right", - "prop_path", - ) - def setup(self) -> None: - """Create arc shapes for LEFT and RIGHT directions.""" - arc_left = create_circle_arc(radius=1.0, direction="LEFT", angle_min=2.0, angle_max=90.0) - arc_right = create_circle_arc(radius=1.0, direction="RIGHT", angle_min=2.0, angle_max=90.0) +def _link_toggle_icon_tris(broken: bool) -> tuple[tuple[float, float, float], ...]: + """Two filled dots joined by a horizontal connector. ``broken=False`` + draws a single continuous bar between the dots' inner edges; + ``broken=True`` shears the two halves vertically — the left dot AND + its stub slip down as a unit, the right dot AND its stub slip up, + with a horizontal gap at the centre. - self.custom_shape_left = self.new_custom_shape(type="TRIS", verts=arc_left) - self.custom_shape_right = self.new_custom_shape(type="TRIS", verts=arc_right) + Each half moves as a cohesive piece so the stub stays attached to its + dot at the same y, reading as a snapped link whose two halves slid + apart rather than as bent stubs jutting out of stationary dots.""" + dot_cx = 0.30 + dot_r = 0.10 + bar_half_thickness = 0.04 + # Inner edge of each dot — the intact bar joins the dot edges, not the + # centres, so the dot + bar reads as one continuous shape. + bar_inner_x = dot_cx - dot_r + segments = 12 + # Vertical shear applied to each half when broken. Zero in the intact + # form keeps both halves on the centerline. + half_offset_y = 0.08 if broken else 0.0 - def _get_shape_for_direction(self, context: bpy.types.Context) -> object: - """Get arc shape based on door swing direction.""" - obj = context.active_object - if not obj: - return self.custom_shape_left + tris: list[tuple[float, float, float]] = [] + for sign in (-1, 1): + cx = sign * dot_cx + cy = sign * half_offset_y + for i in range(segments): + a1 = (2.0 * math.pi) * (i / segments) + a2 = (2.0 * math.pi) * ((i + 1) / segments) + p1 = (cx + dot_r * math.cos(a1), cy + dot_r * math.sin(a1)) + p2 = (cx + dot_r * math.cos(a2), cy + dot_r * math.sin(a2)) + tris.append((cx, cy, 0.0)) + tris.append((p1[0], p1[1], 0.0)) + tris.append((p2[0], p2[1], 0.0)) - try: - direction_value = obj.path_resolve(self.prop_path) - if "RIGHT" in str(direction_value): - return self.custom_shape_right - except (ValueError, KeyError, AttributeError): - pass + if broken: + # Stubs reach inward into the dot's interior so they read as rooted + # in the dot rather than floating off its edge after the slip. + stub_outer_x = 0.27 + stub_inner_x = 0.06 + tris.extend( + rect_tris( + -stub_outer_x, + -half_offset_y - bar_half_thickness, + -stub_inner_x, + -half_offset_y + bar_half_thickness, + ) + ) + tris.extend( + rect_tris( + stub_inner_x, + half_offset_y - bar_half_thickness, + stub_outer_x, + half_offset_y + bar_half_thickness, + ) + ) + else: + tris.extend(rect_tris(-bar_inner_x, -bar_half_thickness, bar_inner_x, bar_half_thickness)) - return self.custom_shape_left + return tuple(tris) + + +LINK_TRIS_INTACT = _link_toggle_icon_tris(broken=False) +LINK_TRIS_BROKEN = _link_toggle_icon_tris(broken=True) + + +class GizmoLinkToggle(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Two-state link glyph: default reads as a connected link (two dots + + intact connector); hover swaps to a broken link (same dots + severed + connector) to signal that a click will sever the underlying connection. + + Single-click gizmo — the target operator is bound via + ``target_set_operator`` by the owning group. The hover swap is purely + visual; the click target is the same in both states. The glyph is + feature-agnostic — any path / link / pair-of-connected-items context can + reuse it for a sever-this-connection affordance.""" + + bl_idname = "VIEW3D_GT_link_toggle" + __slots__ = ("custom_shape",) + # Bbox source for the hit shape. The broken form's vertically-sheared + # halves give it the larger bbox of the two states, so using it as the + # hit-shape source guarantees the clickable area covers either form — + # the cursor doesn't lose hover at the offset dots' outer edges. + tris = LINK_TRIS_BROKEN + + # Per-class batch cache: one entry per highlight state. The mixin parent + # caches one batch per class via ``_get_static_tris_batch``; the per-state + # swap needs a second batch, so this class keeps its own cache. + _batch_cache: ClassVar[dict[bool, "gpu.types.GPUBatch"]] = {} def draw(self, context: bpy.types.Context) -> None: - self.draw_custom_shape(self._get_shape_for_direction(context)) - - def draw_select(self, context: bpy.types.Context, select_id: int) -> None: - self.draw_custom_shape(self._get_shape_for_direction(context), select_id=select_id) + broken = bool(self.is_highlight) + batch = type(self)._batch_cache.get(broken) + if batch is None: + tris = LINK_TRIS_BROKEN if broken else LINK_TRIS_INTACT + batch = batch_for_shader(_get_static_tris_shader(), "TRIS", {"pos": tris}) + type(self)._batch_cache[broken] = batch + # Icon body forced fully opaque so the dark outline behind doesn't + # bleed through and grey out the glyph. + color = (*self.color_highlight, 1.0) if broken else (*self.color, 1.0) + draw_tris_with_outline( + batch, + self.matrix_basis @ self.matrix_offset, + color, + self.outline_width, + self.outline_alpha, + ) -class GizmoPen(bpy.types.Gizmo): +def _fillet_icon_tris() -> tuple[tuple[float, float, float], ...]: + """Filled L-glyph with a smoothly rounded corner — two perpendicular + wall bars joined by a constant-thickness arc band.""" + arc_center_x = 0.0 + arc_center_y = 0.0 + r_outer = 0.28 + r_inner = 0.18 # thickness = 0.10 + arc_segments = 8 + + # Banana sweeps from 270° (downward radial) to 360° = 0° (rightward + # radial). The bars extend the wall material outward from the banana's + # two end caps along the tangent direction. + outer_at_start = (arc_center_x, arc_center_y - r_outer) # 270°, outer + inner_at_start = (arc_center_x, arc_center_y - r_inner) # 270°, inner + outer_at_end = (arc_center_x + r_outer, arc_center_y) # 0°, outer + inner_at_end = (arc_center_x + r_inner, arc_center_y) # 0°, inner + + bar_a_left = -0.45 # horizontal bar extends from banana cap LEFTWARD + bar_b_top = 0.45 # vertical bar extends from banana cap UPWARD + + tris: list[tuple[float, float, float]] = [] + # Horizontal bar: tangent at 270° (downward radial), tangent direction is +X. + # The bar lies along +X with cross-section in radial direction (y). + tris.extend(rect_tris(bar_a_left, outer_at_start[1], outer_at_start[0], inner_at_start[1])) + # Vertical bar: tangent at 0° (rightward radial), tangent direction is +Y. + # The bar lies along +Y with cross-section in radial direction (x). + tris.extend(rect_tris(inner_at_end[0], outer_at_end[1], outer_at_end[0], bar_b_top)) + + # Quarter-banana sector: each angular slice → trapezoid → two CCW triangles. + angle_start = 3.0 * math.pi / 2.0 # 270° + angle_end = 2.0 * math.pi # 360° / 0° + for i in range(arc_segments): + a1 = angle_start + (angle_end - angle_start) * (i / arc_segments) + a2 = angle_start + (angle_end - angle_start) * ((i + 1) / arc_segments) + outer1 = (arc_center_x + r_outer * math.cos(a1), arc_center_y + r_outer * math.sin(a1)) + outer2 = (arc_center_x + r_outer * math.cos(a2), arc_center_y + r_outer * math.sin(a2)) + inner1 = (arc_center_x + r_inner * math.cos(a1), arc_center_y + r_inner * math.sin(a1)) + inner2 = (arc_center_x + r_inner * math.cos(a2), arc_center_y + r_inner * math.sin(a2)) + tris.append((outer1[0], outer1[1], 0.0)) + tris.append((outer2[0], outer2[1], 0.0)) + tris.append((inner2[0], inner2[1], 0.0)) + tris.append((outer1[0], outer1[1], 0.0)) + tris.append((inner2[0], inner2[1], 0.0)) + tris.append((inner1[0], inner1[1], 0.0)) + return tuple(tris) + + +FILLET_TRIS_DEFAULT = _fillet_icon_tris() + + +class GizmoFillet(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Filled fillet glyph for wall-corner rounding.""" + + bl_idname = "VIEW3D_GT_fillet" + __slots__ = ("custom_shape",) + tris = FILLET_TRIS_DEFAULT + # Stacked at ICON_STACK_OFFSET_Y above the join icon in the wall-join + # gizmo group; full-bbox hit overlaps the sibling icons' bboxes and + # steals their clicks. + hit_uses_bbox = False + + +def _wall_corner_icon_tris() -> tuple[tuple[float, float, float], ...]: + """Filled L-glyph with a sharp 90° inner corner.""" + # Match the fillet icon's bar thickness so the row reads at one visual weight. + outer_y = -0.28 + inner_y = -0.18 + outer_x = 0.28 + inner_x = 0.18 + bar_a_left = -0.45 + bar_b_top = 0.45 + + tris: list[tuple[float, float, float]] = [] + # Bars overlap at the corner square so the L renders as one continuous material. + tris.extend(rect_tris(bar_a_left, outer_y, outer_x, inner_y)) + tris.extend(rect_tris(inner_x, outer_y, outer_x, bar_b_top)) + return tuple(tris) + + +WALL_CORNER_TRIS_DEFAULT = _wall_corner_icon_tris() + + +class GizmoWallCornerIcon(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Filled L-shape glyph (sharp 90° corner) for joining two walls.""" + + bl_idname = "VIEW3D_GT_wall_corner" + __slots__ = ("custom_shape",) + tris = WALL_CORNER_TRIS_DEFAULT + hit_uses_bbox = False # tight stack in GizmoWallJoinIntersection — see GizmoFillet + + +def _wall_tee_icon_tris() -> tuple[tuple[float, float, float], ...]: + """Filled side-T glyph (⊣ orientation) for extending one wall into + another's side. The through wall (vertical bar, right edge) carries a + branching wall (horizontal bar) butting into its midline — visually + distinguishes 'extend wall to wall' from the L-corner 'join' glyph by + *where* the bars meet (middle vs corner).""" + # Match the wall-corner bbox + bar thickness so the icon row reads at + # one visual weight. + bar_lo_y = -0.28 + bar_top = 0.45 + through_inner_x = 0.18 + through_outer_x = 0.28 + branch_left = -0.45 + # Branching bar centered on the through-bar's midline so the vertical + # extends equally above and below — reads as a balanced ⊣. + branch_mid_y = (bar_lo_y + bar_top) / 2 + branch_half_thickness = 0.05 + + tris: list[tuple[float, float, float]] = [] + tris.extend(rect_tris(through_inner_x, bar_lo_y, through_outer_x, bar_top)) + # Branching bar's right edge stops at the through-bar's inner edge so the + # bars touch without overlapping. + tris.extend( + rect_tris( + branch_left, + branch_mid_y - branch_half_thickness, + through_inner_x, + branch_mid_y + branch_half_thickness, + ) + ) + return tuple(tris) + + +WALL_TEE_TRIS_DEFAULT = _wall_tee_icon_tris() + + +class GizmoWallTeeIcon(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Filled T-junction glyph for extending one wall into another's side.""" + + bl_idname = "VIEW3D_GT_wall_tee" + __slots__ = ("custom_shape",) + tris = WALL_TEE_TRIS_DEFAULT + hit_uses_bbox = False # tight stack in GizmoWallJoinIntersection — see GizmoFillet + + +class GizmoPen(StaticTrisGizmoMixin, bpy.types.Gizmo): """Pen/edit icon gizmo for entering edit mode.""" bl_idname = "VIEW3D_GT_pen" @@ -2892,17 +3622,8 @@ class GizmoPen(bpy.types.Gizmo): (0.21042980253696442, 0.321493536233902, 0.0), ) - def setup(self) -> None: - self.custom_shape = self.new_custom_shape("TRIS", self.tris) - def draw(self, context: bpy.types.Context) -> None: - self.draw_custom_shape(self.custom_shape) - - def draw_select(self, context: bpy.types.Context, select_id: int) -> None: - self.draw_custom_shape(self.custom_shape, select_id=select_id) - - -class GizmoValidate(bpy.types.Gizmo): +class GizmoValidate(StaticTrisGizmoMixin, bpy.types.Gizmo): """Validate/checkmark icon gizmo for confirming edits.""" bl_idname = "VIEW3D_GT_validate" @@ -2924,17 +3645,8 @@ class GizmoValidate(bpy.types.Gizmo): (0.030080009251832962, -0.1881658434867859, 0.0), ) - def setup(self) -> None: - self.custom_shape = self.new_custom_shape("TRIS", self.tris) - def draw(self, context: bpy.types.Context) -> None: - self.draw_custom_shape(self.custom_shape) - - def draw_select(self, context: bpy.types.Context, select_id: int) -> None: - self.draw_custom_shape(self.custom_shape, select_id=select_id) - - -class GizmoCancel(bpy.types.Gizmo): +class GizmoCancel(StaticTrisGizmoMixin, bpy.types.Gizmo): """Cancel/X icon gizmo for canceling edits.""" bl_idname = "VIEW3D_GT_cancel" @@ -2974,17 +3686,8 @@ class GizmoCancel(bpy.types.Gizmo): (0.048707593232393265, 0.0, 0.0), ) - def setup(self) -> None: - self.custom_shape = self.new_custom_shape("TRIS", self.tris) - def draw(self, context: bpy.types.Context) -> None: - self.draw_custom_shape(self.custom_shape) - - def draw_select(self, context: bpy.types.Context, select_id: int) -> None: - self.draw_custom_shape(self.custom_shape, select_id=select_id) - - -class GizmoPlus(bpy.types.Gizmo): +class GizmoPlus(StaticTrisGizmoMixin, bpy.types.Gizmo): """Plus icon gizmo for incrementing values.""" bl_idname = "VIEW3D_GT_plus" @@ -3006,17 +3709,8 @@ class GizmoPlus(bpy.types.Gizmo): (0.075, -0.375, 0.0), ) - def setup(self) -> None: - self.custom_shape = self.new_custom_shape("TRIS", self.tris) - def draw(self, context: bpy.types.Context) -> None: - self.draw_custom_shape(self.custom_shape) - - def draw_select(self, context: bpy.types.Context, select_id: int) -> None: - self.draw_custom_shape(self.custom_shape, select_id=select_id) - - -class GizmoMinus(bpy.types.Gizmo): +class GizmoMinus(StaticTrisGizmoMixin, bpy.types.Gizmo): """Minus icon gizmo for decrementing values.""" bl_idname = "VIEW3D_GT_minus" @@ -3032,15 +3726,482 @@ class GizmoMinus(bpy.types.Gizmo): (0.375, -0.075, 0.0), ) - def setup(self) -> None: - self.custom_shape = self.new_custom_shape("TRIS", self.tris) + +class GizmoTrash(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Wastebasket icon for destructive delete actions — body + lid + handle.""" + + bl_idname = "VIEW3D_GT_trash" + + __slots__ = ("custom_shape",) + + # Trash-can profile within the conventional ±0.375 icon bounding box. + # Sized ~15% larger than the baseline 3-rect icon design so the + # destructive button reads as the visual end-stop of the row. Solid + # fills match the Bonsai gizmo-icon convention (Plus / Minus / Cancel). + tris = ( + # Body — slightly narrower than the lid for the classic bin shape. + *rect_tris(-0.23, -0.345, 0.23, 0.207), + # Lid — extends wider on both sides so it sits "over" the body. + *rect_tris(-0.31, 0.207, 0.31, 0.30), + # Handle — small bar centered on top of the lid. + *rect_tris(-0.09, 0.30, 0.09, 0.39), + ) + + +class GizmoArrayParent(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Hierarchy tree glyph — one top node connected to three bottom nodes + by short lines. Fires the operator that selects the parent object of an + array given a child is currently active.""" + + bl_idname = "VIEW3D_GT_array_parent" + + __slots__ = ("custom_shape",) + + # Hierarchy tree: one top node + three bottom nodes wired by trunk + + # crossbar + drop legs. Conventional ±0.375 icon bounding box. + tris = ( + # Top (parent) node. + *rect_tris(-0.075, 0.195, 0.075, 0.345), + # Three child nodes along the bottom row. + *rect_tris(-0.335, -0.335, -0.205, -0.205), + *rect_tris(-0.065, -0.335, 0.065, -0.205), + *rect_tris(0.205, -0.335, 0.335, -0.205), + # Vertical trunk: top node down through the crossbar to the centre child. + *rect_tris(-0.02, -0.205, 0.02, 0.195), + # Horizontal crossbar joining the trunk's midpoint to left/right legs. + *rect_tris(-0.27, -0.07, 0.27, -0.03), + # Drop legs from crossbar to the left and right children. + *rect_tris(-0.29, -0.205, -0.25, -0.07), + *rect_tris(0.25, -0.205, 0.29, -0.07), + ) + + +def _quad_tris(x0: float, y0: float, x1: float, y1: float) -> tuple: + """Two CCW triangles covering rectangle ``(x0,y0)-(x1,y1)`` in Z=0.""" + return ( + (x0, y0, 0.0), (x1, y0, 0.0), (x1, y1, 0.0), + (x0, y0, 0.0), (x1, y1, 0.0), (x0, y1, 0.0), + ) # fmt: skip + + +# 7-segment digit definitions for world-space integer-label gizmos. Each digit's +# strokes fit inside a unit-cell (width 0.22, height 0.40) centred on (0, 0); the +# label builder translates the cell into the final position. Composed of seven +# rectangle "segments" — top, mid, bot horizontals + upper-left/right and +# lower-left/right verticals — so the count gizmo can render any integer 0-9999 +# without an external font. +_DIGIT_STROKES = { + "top": (-0.10, 0.18, 0.10, 0.20), + "mid": (-0.10, -0.02, 0.10, 0.02), + "bot": (-0.10, -0.20, 0.10, -0.18), + "ul": (-0.10, 0.00, -0.07, 0.20), + "ur": (0.07, 0.00, 0.10, 0.20), + "ll": (-0.10, -0.20, -0.07, 0.00), + "lr": (0.07, -0.20, 0.10, 0.00), +} # fmt: skip +_DIGIT_SEGMENTS = { + "0": ("top", "ul", "ur", "ll", "lr", "bot"), + "1": ("ur", "lr"), + "2": ("top", "ur", "mid", "ll", "bot"), + "3": ("top", "ur", "mid", "lr", "bot"), + "4": ("ul", "ur", "mid", "lr"), + "5": ("top", "ul", "mid", "lr", "bot"), + "6": ("top", "ul", "mid", "ll", "lr", "bot"), + "7": ("top", "ur", "lr"), + "8": ("top", "ul", "ur", "mid", "ll", "lr", "bot"), + "9": ("top", "ul", "ur", "mid", "lr", "bot"), +} +# Width of one digit cell including its trailing kerning gap. ``x`` prefix is +# rendered as two crossed diagonals across one cell of the same width. +_DIGIT_CELL_W = 0.26 + + +def _digit_tris(digit: str, cx: float, cy: float) -> tuple: + """Triangles for one ``"0"``..``"9"`` digit centred on ``(cx, cy)``.""" + tris: list[tuple[float, float, float]] = [] + for seg in _DIGIT_SEGMENTS[digit]: + x0, y0, x1, y1 = _DIGIT_STROKES[seg] + tris.extend(_quad_tris(x0 + cx, y0 + cy, x1 + cx, y1 + cy)) + return tuple(tris) + + +def _x_prefix_tris(cx: float, cy: float) -> tuple: + """Triangles for an ``x`` glyph centred on ``(cx, cy)`` — two crossed + diagonals roughly matching a digit's height for the count label.""" + # Each leg is a thin rectangle rotated 45° from the cell centre. Vertex + # coords are precomputed: half-length 0.13 along the rotated axis, half + # width 0.025 perpendicular. Using two quads keeps it TRIS-only. + leg = 0.13 + w = 0.025 + # Leg 1 (top-left → bottom-right). + p1 = (cx - leg - w, cy + leg - w, 0.0) + p2 = (cx - leg + w, cy + leg + w, 0.0) + p3 = (cx + leg + w, cy - leg + w, 0.0) + p4 = (cx + leg - w, cy - leg - w, 0.0) + # Leg 2 (top-right → bottom-left). + q1 = (cx + leg - w, cy + leg + w, 0.0) + q2 = (cx + leg + w, cy + leg - w, 0.0) + q3 = (cx - leg + w, cy - leg - w, 0.0) + q4 = (cx - leg - w, cy - leg + w, 0.0) + return ( + p1, p2, p3, p1, p3, p4, + q1, q2, q3, q1, q3, q4, + ) # fmt: skip + + +def _count_label_tris(count: int, cx: float, cy: float) -> tuple: + """Triangles for an ``xN`` label centred on ``(cx, cy)``. Composes the + ``x`` prefix and each base-10 digit horizontally.""" + digits = str(max(0, int(count))) + total_w = _DIGIT_CELL_W * (1 + len(digits)) + start_x = cx - total_w / 2 + _DIGIT_CELL_W / 2 + tris: list[tuple[float, float, float]] = [] + tris.extend(_x_prefix_tris(start_x, cy)) + for i, d in enumerate(digits): + tris.extend(_digit_tris(d, start_x + (i + 1) * _DIGIT_CELL_W, cy)) + return tuple(tris) + + +class GizmoArrayAll(StaticTrisGizmoMixin, bpy.types.Gizmo): + """2×2 grid of small filled squares — multi-select for an array + (parent + all children). + + On hover from an array child, paints a wireframe bbox around every + sibling in the same array layer.""" + + bl_idname = "VIEW3D_GT_array_all" + + __slots__ = ("custom_shape",) + + # Four small filled squares in a 2x2 grid, each 0.2 wide with a 0.15 gap + # between rows / columns so the grid reads as discrete cells rather than a + # solid block. All within the ±0.375 icon bounding-box convention. + tris = ( + *_quad_tris(-0.275, 0.075, -0.075, 0.275), # top-left + *_quad_tris(0.075, 0.075, 0.275, 0.275), # top-right + *_quad_tris(-0.275, -0.275, -0.075, -0.075), # bottom-left + *_quad_tris(0.075, -0.275, 0.275, -0.075), # bottom-right + ) def draw(self, context: bpy.types.Context) -> None: - self.draw_custom_shape(self.custom_shape) + super().draw(context) + if self.is_highlight: + self._draw_containing_array_bbox(context) + + def _draw_containing_array_bbox(self, context: bpy.types.Context) -> None: + """Outline every sibling of the active child in the array layer that + produced it. No-op when no resolvable parent / layer.""" + obj = context.active_object + if obj is None: + return + child_element = tool.Ifc.get_entity(obj) + if child_element is None: + return + layer_index = tool.Array.get_child_layer_index(child_element) + if layer_index is None: + return + pset = ifcopenshell.util.element.get_pset(child_element, "BBIM_Array") + if not pset: + return + parent_guid = pset.get("Parent") + if not parent_guid: + return + try: + parent_element = tool.Ifc.get().by_guid(parent_guid) + except RuntimeError: + return + from bonsai.bim.module.model.array import draw_array_layer_children_bbox + + draw_array_layer_children_bbox(context, parent_element, layer_index) + + +class GizmoArrayLayerIndicator(bpy.types.Gizmo): + """ARRAY layer entry icon with a world-space ``xN`` count rendered above. + + The 2×2-grid glyph sits in the bottom half of the local frame; the + ``xN`` count is composed of 7-segment digit triangles in the top half. + Both are part of the gizmo's custom shape so the entire glyph is a + single click target. + + On hover, ``draw()`` paints a wireframe bbox around every child of this + layer in the same 3D pass — drawing inline keeps the bbox in lockstep + with the highlight.""" + + bl_idname = "BIM_GT_array_layer_indicator" + + __slots__ = ("custom_shape", "_count", "_built_count", "_layer_index", "_outlined_batch") + + # Icon glyph (2×2 grid) translated down so the upper half stays free for + # the count label. Centred so the gizmo's world anchor falls between the + # icon and the label. + _ICON_TRIS = ( + *_quad_tris(-0.275, -0.475, -0.075, -0.275), + *_quad_tris(0.075, -0.475, 0.275, -0.275), + *_quad_tris(-0.275, -0.225, -0.075, -0.025), + *_quad_tris(0.075, -0.225, 0.275, -0.025), + ) + # Vertical centre of the count label in the gizmo's local frame. + _LABEL_Y = 0.22 + + def setup(self) -> None: + self._count = 0 + self._built_count = -1 + # ``-1`` until the gizmo group calls ``set_layer_index``. The bbox + # highlight no-ops while the index is unassigned. + self._layer_index = -1 + tris = self._build_tris() + self.custom_shape = self.new_custom_shape("TRIS", tris) + self._outlined_batch = batch_for_shader(_get_static_tris_shader(), "TRIS", {"pos": tris}) + self._built_count = 0 + + def set_count(self, count: int) -> None: + self._count = int(count) + + def set_layer_index(self, layer_index: int) -> None: + self._layer_index = int(layer_index) + + def _build_tris(self) -> tuple: + return self._ICON_TRIS + _count_label_tris(self._count, 0.0, self._LABEL_Y) + + def _ensure_shape(self) -> None: + if self._built_count != self._count: + tris = self._build_tris() + self.custom_shape = self.new_custom_shape("TRIS", tris) + self._outlined_batch = batch_for_shader(_get_static_tris_shader(), "TRIS", {"pos": tris}) + self._built_count = self._count + + def draw(self, context: bpy.types.Context) -> None: + self._ensure_shape() + if self.is_highlight: + color = (*self.color_highlight, 1.0) + else: + color = (*self.color, 1.0) + draw_tris_with_outline(self._outlined_batch, self.matrix_basis @ self.matrix_offset, color) + if self.is_highlight: + self._draw_layer_children_bbox(context) def draw_select(self, context: bpy.types.Context, select_id: int) -> None: + self._ensure_shape() self.draw_custom_shape(self.custom_shape, select_id=select_id) + def _draw_layer_children_bbox(self, context: bpy.types.Context) -> None: + """Outline this layer's children inline so the bbox stays in lockstep + with the gizmo highlight.""" + if self._layer_index < 0: + return + obj = context.active_object + if obj is None: + return + parent_element = tool.Ifc.get_entity(obj) + if parent_element is None: + return + from bonsai.bim.module.model.array import draw_array_layer_children_bbox + + draw_array_layer_children_bbox(context, parent_element, self._layer_index) + + +class GizmoCountLabel(bpy.types.Gizmo): + """``xN`` text label rendered from 7-segment digit triangles. + + Mirrors a caller-supplied integer into a live count badge. No icon + glyph; the gizmo is the number alone.""" + + bl_idname = "BIM_GT_count_label" + + __slots__ = ("custom_shape", "_count", "_built_count", "_outlined_batch") + + def setup(self) -> None: + self._count = 0 + self._built_count = -1 + tris = _count_label_tris(self._count, 0.0, 0.0) + self.custom_shape = self.new_custom_shape("TRIS", tris) + self._outlined_batch = batch_for_shader(_get_static_tris_shader(), "TRIS", {"pos": tris}) + self._built_count = 0 + + def set_count(self, count: int) -> None: + self._count = int(count) + + def _ensure_shape(self) -> None: + if self._built_count != self._count: + tris = _count_label_tris(self._count, 0.0, 0.0) + self.custom_shape = self.new_custom_shape("TRIS", tris) + self._outlined_batch = batch_for_shader(_get_static_tris_shader(), "TRIS", {"pos": tris}) + self._built_count = self._count + + def draw(self, context: bpy.types.Context) -> None: + self._ensure_shape() + color = (*self.color_highlight, 1.0) if self.is_highlight else (*self.color, 1.0) + draw_tris_with_outline(self._outlined_batch, self.matrix_basis @ self.matrix_offset, color) + + def draw_select(self, context: bpy.types.Context, select_id: int) -> None: + self._ensure_shape() + self.draw_custom_shape(self.custom_shape, select_id=select_id) + + +class GizmoMerge(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Two arrows pointing inward toward each other — conveys joining/merging elements.""" + + bl_idname = "VIEW3D_GT_merge" + + __slots__ = ("custom_shape",) + + # Two solid triangles pointing toward the center on the horizontal axis, + # plus two thin tails behind each tip to make them read as arrows rather than + # standalone triangles. + tris = ( + # Left arrowhead pointing right (tip at x≈-0.05). + (-0.35, -0.20, 0.0), + (-0.35, 0.20, 0.0), + (-0.05, 0.0, 0.0), + # Left tail behind the arrowhead. + *rect_tris(-0.45, -0.06, -0.30, 0.06), + # Right arrowhead pointing left (tip at x≈0.05). + (0.35, -0.20, 0.0), + (0.35, 0.20, 0.0), + (0.05, 0.0, 0.0), + # Right tail behind the arrowhead. + *rect_tris(0.30, -0.06, 0.45, 0.06), + ) + + +class GizmoSplit(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Two arrows pointing outward away from each other — conveys splitting/cutting + one element into two. Visual inverse of `GizmoMerge`.""" + + bl_idname = "VIEW3D_GT_split" + + __slots__ = ("custom_shape",) + + # Two solid triangles pointing OUTWARD on the horizontal axis (tips at x=±0.35), + # with tails extending toward the centerline. The tails meet at center to form a + # short horizontal bar, suggesting the split point itself. + tris = ( + # Left arrowhead pointing left (tip at x=-0.35). + (-0.05, -0.20, 0.0), + (-0.05, 0.20, 0.0), + (-0.35, 0.0, 0.0), + # Left tail extending toward the right (away from the tip, toward center). + *rect_tris(-0.05, -0.06, 0.10, 0.06), + # Right arrowhead pointing right (tip at x=0.35). + (0.05, -0.20, 0.0), + (0.05, 0.20, 0.0), + (0.35, 0.0, 0.0), + # Right tail extending toward the left. + *rect_tris(-0.10, -0.06, 0.05, 0.06), + ) + + +class GizmoUnjoin(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Two C-shaped hooks facing each other across a clear gap — conveys + severing a relationship between two elements (e.g. an + ``IfcRelConnectsPathElements`` between two walls). The "two linked + things pulled apart" silhouette reads as relationship-cut rather than + geometry-cut.""" + + bl_idname = "VIEW3D_GT_unjoin" + + __slots__ = ("custom_shape",) + + # Each hook is three solid bars composing a C: top, bottom, and back + # wall. The two C's face inward across a clear gap so the silhouette + # reads as "two interlocking links pulled apart". + tris = ( + # Left hook — C opening to the right. + *rect_tris(-0.30, 0.11, -0.08, 0.17), + *rect_tris(-0.30, -0.17, -0.08, -0.11), + *rect_tris(-0.30, -0.17, -0.24, 0.17), + # Right hook — mirror, C opening to the left. + *rect_tris(0.08, 0.11, 0.30, 0.17), + *rect_tris(0.08, -0.17, 0.30, -0.11), + *rect_tris(0.24, -0.17, 0.30, 0.17), + ) + + +class GizmoExtend(StaticTrisGizmoMixin, bpy.types.Gizmo): + """An arrow pointing into a vertical bar — conveys extending an element to a target + line (e.g. extending a wall to the 3D cursor).""" + + bl_idname = "VIEW3D_GT_extend" + + __slots__ = ("custom_shape",) + + # Layout: thick vertical bar at the right edge (the "target") with a horizontal + # arrow pointing into it from the left. + tris = ( + # Vertical target bar (x = 0.25 to 0.35, full height). + *rect_tris(0.25, -0.30, 0.35, 0.30), + # Arrowhead pointing right toward the bar (tip at x=0.20). + (-0.05, -0.18, 0.0), + (-0.05, 0.18, 0.0), + (0.20, 0.0, 0.0), + # Tail extending leftward from the arrowhead base. + *rect_tris(-0.35, -0.06, -0.05, 0.06), + ) + + +class GizmoExtendVertical(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Vertical sibling of `GizmoExtend` — arrow pointing UP into a horizontal + bar. Conveys extending an element's height to a target Z.""" + + bl_idname = "VIEW3D_GT_extend_vertical" + + __slots__ = ("custom_shape",) + + # Mechanically derived from GizmoExtend by reflecting across Y=X. + tris = swap_xy_tris(GizmoExtend.tris) + + +def _offset_baseline_tris(mark_x: float) -> tuple[tuple[float, float, float], ...]: + """Shared geometry for the three offset-baseline icons: a horizontal "wall + section" bar with a vertical mark at ``mark_x`` indicating where the reference + axis sits within the wall thickness. Matches the visual convention used in the + Bonsai N-panel's wall Align row.""" + return rect_tris(-0.25, -0.07, 0.25, 0.07) + rect_tris(mark_x - 0.04, -0.22, mark_x + 0.04, 0.22) + + +class GizmoOffsetExterior(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Wall offset baseline indicator — reference axis at the exterior face (left mark).""" + + bl_idname = "VIEW3D_GT_offset_exterior" + __slots__ = ("custom_shape",) + tris = _offset_baseline_tris(-0.24) + + +class GizmoOffsetCenter(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Wall offset baseline indicator — reference axis at the centreline (middle mark).""" + + bl_idname = "VIEW3D_GT_offset_center" + __slots__ = ("custom_shape",) + tris = _offset_baseline_tris(0.0) + + +class GizmoOffsetInterior(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Wall offset baseline indicator — reference axis at the interior face (right mark).""" + + bl_idname = "VIEW3D_GT_offset_interior" + __slots__ = ("custom_shape",) + tris = _offset_baseline_tris(0.24) + + +class GizmoAddOpening(StaticTrisGizmoMixin, bpy.types.Gizmo): + """A rectangular frame (square outline with a hole in the middle) — conveys adding an + opening (window/door/void) to a wall.""" + + bl_idname = "VIEW3D_GT_add_opening" + + __slots__ = ("custom_shape",) + + # Outer 0.40 × 0.40 square with a 0.25 × 0.25 inner hole, drawn as four bars + # forming a frame, plus a small "+" in the inner hole to convey "add". + tris = ( + *rect_tris(-0.20, 0.125, 0.20, 0.20), # Top bar + *rect_tris(-0.20, -0.20, 0.20, -0.125), # Bottom bar + *rect_tris(-0.20, -0.125, -0.125, 0.125), # Left bar + *rect_tris(0.125, -0.125, 0.20, 0.125), # Right bar + *rect_tris(-0.07, -0.015, 0.07, 0.015), # "+" horizontal stroke + *rect_tris(-0.015, -0.07, 0.015, 0.07), # "+" vertical stroke + ) + def _generate_circular_arrow_tris() -> tuple[tuple[float, float, float], ...]: """Generate circular arrow geometry covering ~300 degrees.""" @@ -3135,7 +4296,7 @@ def _generate_circular_arrow_tris() -> tuple[tuple[float, float, float], ...]: return tuple(triangles) -class GizmoCycle(bpy.types.Gizmo): +class GizmoCycle(StaticTrisGizmoMixin, bpy.types.Gizmo): """Circular arrow icon gizmo for cycling through enum values.""" bl_idname = "VIEW3D_GT_cycle" @@ -3144,14 +4305,43 @@ class GizmoCycle(bpy.types.Gizmo): tris = _generate_circular_arrow_tris() - def setup(self) -> None: - self.custom_shape = self.new_custom_shape("TRIS", self.tris) - def draw(self, context: bpy.types.Context) -> None: - self.draw_custom_shape(self.custom_shape) +def _generate_menu_tris() -> tuple[tuple[float, float, float], ...]: + """Three stacked horizontal bars — universal "menu / pick from list" glyph.""" + # Sized ~30% larger than the validate / cancel icon family so the picker + # affordance reads more strongly — picking a type is a higher-stakes click + # than the surrounding edit-mode toggles. + bar_half_thickness = 0.046 + bar_half_width = 0.26 + vertical_spacing = 0.182 + return ( + *rect_tris( + -bar_half_width, + +vertical_spacing - bar_half_thickness, + +bar_half_width, + +vertical_spacing + bar_half_thickness, + ), + *rect_tris(-bar_half_width, -bar_half_thickness, +bar_half_width, +bar_half_thickness), + *rect_tris( + -bar_half_width, + -vertical_spacing - bar_half_thickness, + +bar_half_width, + -vertical_spacing + bar_half_thickness, + ), + ) - def draw_select(self, context: bpy.types.Context, select_id: int) -> None: - self.draw_custom_shape(self.custom_shape, select_id=select_id) + +class GizmoMenu(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Hamburger-stack menu icon — 'open a picker to choose from many options'. + + For enums with 3+ values; use ``GizmoCycle`` for exactly 2 (where the + advance-one-per-click semantic stays predictable).""" + + bl_idname = "VIEW3D_GT_menu" + + __slots__ = ("custom_shape",) + + tris = _generate_menu_tris() class GizmoArrow(GizmoMovable): @@ -3160,7 +4350,7 @@ class GizmoArrow(GizmoMovable): bl_idname = "BIM_GT_gizmo_arrow" bl_target_properties = ({"id": "offset", "type": "FLOAT", "array_length": 1},) - def _get_arrow_triangles(self) -> tuple[tuple[float, float, float], ...]: + def _get_triangles(self) -> tuple[tuple[float, float, float], ...]: triangles = [] triangles.extend( @@ -3229,16 +4419,10 @@ class GizmoArrow(GizmoMovable): return tuple(triangles) - def setup(self) -> None: - self.custom_shape = self.new_custom_shape("TRIS", self._get_arrow_triangles()) - def draw(self, context: bpy.types.Context) -> None: self.draw_custom_shape(self.custom_shape) self.draw_property_tooltip(context) - def draw_select(self, context: bpy.types.Context, select_id: int) -> None: - self.draw_custom_shape(self.custom_shape, select_id=select_id) - class GizmoArrow2D(GizmoMovable): """Flat 2D arrow that rotates around its axis to face the camera.""" @@ -3251,7 +4435,7 @@ class GizmoArrow2D(GizmoMovable): ARROW_2D_WIDTH = 0.25 ARROW_2D_HEAD_WIDTH = 0.75 - def _get_arrow_2d_triangles(self) -> tuple[tuple[float, float, float], ...]: + def _get_triangles(self) -> tuple[tuple[float, float, float], ...]: """Generate flat arrow geometry in XY plane, pointing along +X.""" shaft = self.ARROW_2D_SHAFT_LENGTH head = self.ARROW_2D_HEAD_LENGTH @@ -3272,16 +4456,10 @@ class GizmoArrow2D(GizmoMovable): (shaft, hw, 0), ) - def setup(self) -> None: - self.custom_shape = self.new_custom_shape("TRIS", self._get_arrow_2d_triangles()) - def draw(self, context: bpy.types.Context) -> None: self.draw_custom_shape(self.custom_shape) self.draw_property_tooltip(context) - def draw_select(self, context: bpy.types.Context, select_id: int) -> None: - self.draw_custom_shape(self.custom_shape, select_id=select_id) - def draw_prepare(self, context: bpy.types.Context) -> None: """Rotate around arrow axis to face camera.""" position = self.matrix_basis.translation @@ -3321,7 +4499,7 @@ class GizmoCone(GizmoMovable): bl_idname = "BIM_GT_gizmo_cone" bl_target_properties = ({"id": "offset", "type": "FLOAT", "array_length": 1},) - def _get_cone_triangles(self) -> tuple[tuple[float, float, float], ...]: + def _get_triangles(self) -> tuple[tuple[float, float, float], ...]: triangles = [] cone_tip_x = CONE_LENGTH @@ -3352,15 +4530,9 @@ class GizmoCone(GizmoMovable): return tuple(triangles) - def setup(self) -> None: - self.custom_shape = self.new_custom_shape("TRIS", self._get_cone_triangles()) - def draw(self, context: bpy.types.Context) -> None: self.draw_custom_shape(self.custom_shape) - def draw_select(self, context: bpy.types.Context, select_id: int) -> None: - self.draw_custom_shape(self.custom_shape, select_id=select_id) - class GizmoDimension(GizmoMovable): """Dimension line gizmo that displays a measurement with extension lines and text. @@ -3421,6 +4593,8 @@ class GizmoDimension(GizmoMovable): "_original_value", # Original property value before interaction "_click_offset", # Offset from dimension tip to click position (for snap correction) "show_extension_lines", # Whether to show extension lines at dimension endpoints + "text_formatter", # Optional (props, value) -> str to override the default dimension label + "schematic_attr_name", # Set by BaseSchematicGizmoGroup: attr_name of the bound config, read by hover-highlight ) ARROW_SIZE = 10 @@ -3479,6 +4653,16 @@ class GizmoDimension(GizmoMovable): start_world = self.matrix_basis.translation.copy() end_world = start_world + axis_world * self._dimension_length + display_value = getattr(self, "_display_value", self._dimension_length) + text_formatter = getattr(self, "text_formatter", None) + gizmo_group = getattr(self, "gizmo_group", None) + display_text: str | None = None + if text_formatter is not None and gizmo_group is not None: + obj = bpy.context.active_object + props = gizmo_group.get_props(obj) if obj is not None else None + if props is not None: + display_text = text_formatter(props, display_value) + DimensionRenderer.get_instance().draw( context=context, start_world=start_world, @@ -3496,7 +4680,8 @@ class GizmoDimension(GizmoMovable): text_offset_sign=getattr(self, "text_offset_sign", 1), text_alignment=getattr(self, "text_alignment", TextAlignment.CENTER), prop_name=getattr(self, "prop_name", None), - display_value=getattr(self, "_display_value", self._dimension_length), + display_value=display_value, + display_text=display_text, ) def _calculate_screen_endpoints(self, context: bpy.types.Context) -> tuple[Vector, Vector, Vector, float] | None: @@ -3615,6 +4800,11 @@ class GizmoDimension(GizmoMovable): self._display_value = max(-10000.0, min(length, 10000.0)) # Clamp to valid range (0 to 10000 meters is reasonable for BIM) for drawing self._dimension_length = max(0.0, min(abs(length), 10000.0)) + # Smaller dimensions win selection when hit regions overlap: a long gizmo's + # hit box fully contains a nested short one's, so without a bias the long + # one wins and the short one is unreachable. The long one stays clickable + # at its exposed ends regardless of bias. + self.select_bias = -self._dimension_length def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set: """Initialize dimension gizmo interaction with click-position tracking. @@ -3865,52 +5055,177 @@ class GizmoDimension(GizmoMovable): clear_snap_cache() -class CycleTypeMixin: - """Mixin for operators that cycle through type literals. +class BillboardingGizmoGroupMixin: + """Mixin for standalone ``bpy.types.GizmoGroup`` classes whose icons must billboard + (face the camera) and re-position every frame. - Subclasses must define: - element_checker: Class method name on tool.Blender.Modifier (e.g., "is_door") - props_getter: Method name on tool.Model (e.g., "get_door_props") - type_literal: The type literal from tool.Model (e.g., tool.Model.DoorType) - type_attr: Attribute name on props for the type (e.g., "door_type") + Blender calls ``GizmoGroup.refresh()`` only on state-change events (selection, + property change, dependency update) — not on camera rotation. A gizmo group that + only sets ``matrix_basis`` in ``refresh()`` will appear to "freeze" its rotation + at the camera angle in effect when it was last refreshed; orbiting the camera + leaves the icon facing the wrong way. - Optional: - skip_element_check: If True, skip the element type validation (default False) - """ + ``draw_prepare()`` *is* called every redraw, so the fix is to run the same + positioning code from both events. Rather than overriding ``refresh()`` and + ``draw_prepare()`` in every gizmo group that has this need, subclass this mixin + and implement a single ``position_gizmos(context)`` method. - element_checker: str - props_getter: str - type_literal: type - type_attr: str - skip_element_check: bool = False + Usage:: - reverse: bpy.props.BoolProperty(name="Reverse", default=False, options={"HIDDEN", "SKIP_SAVE"}) + class MyGizmoGroup(bpy.types.GizmoGroup, BillboardingGizmoGroupMixin): + bl_idname = "..." + ... + def setup(self, context): + ... + def position_gizmos(self, context): + # set matrix_basis on every gizmo here, using get_billboard_rotation + # for any icon that should face the camera. + ... - def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: - """Set reverse direction based on Shift key.""" - self.reverse = event.shift - return self.execute(context) + ``position_gizmos`` should be idempotent — it's called twice when a state change + coincides with a redraw (once via ``refresh``, once via ``draw_prepare``).""" - def _cycle_type(self, context: bpy.types.Context) -> set[str]: - """Common type cycling logic. Call from execute() or _execute().""" - obj = context.active_object - if not obj: - return {"CANCELLED"} + def refresh(self, context: bpy.types.Context) -> None: + self.position_gizmos(context) - if not self.skip_element_check: - element = tool.Ifc.get_entity(obj) - checker = getattr(tool.Blender.Modifier, self.element_checker) - if not element or not checker(element): - return {"CANCELLED"} + def draw_prepare(self, context: bpy.types.Context) -> None: + if apply_transform_modal_draw_gate(self, context): + return + self.position_gizmos(context) - props = getattr(tool.Model, self.props_getter)(obj) - types = get_args(self.type_literal) - current = getattr(props, self.type_attr) - idx = types.index(current) if current in types else 0 - direction = -1 if self.reverse else 1 - setattr(props, self.type_attr, types[(idx + direction) % len(types)]) + def setup_icon_gizmo( + self, + gizmo_type: str, + color: tuple[float, float, float], + highlight_color: tuple[float, float, float], + operator: str, + alpha: float = 0.8, + ) -> bpy.types.Gizmo: + """Convenience wrapper over `setup_icon_gizmo` for subclasses.""" + return setup_icon_gizmo(self, gizmo_type, color, highlight_color, operator, alpha) - return {"FINISHED"} + def get_decoration_colors(self) -> tuple[tuple[float, float, float], tuple[float, float, float]]: + """Standard (default, highlight) color pair for active-state gizmos. + Pulls from the addon preferences — same source consumed by every + Bonsai decorator. Hover-class gizmos that should not pull focus + should use ``get_unselected_decoration_colors`` instead.""" + prefs = tool.Blender.get_addon_preferences() + return prefs.decorations_colour[:3], prefs.decorator_color_selected[:3] + + def get_unselected_decoration_colors(self) -> tuple[tuple[float, float, float], tuple[float, float, float]]: + """Lower-priority (unselected default, highlight) pair for gizmos + that surface on already-selected geometry and shouldn't compete + visually with the selection outline (e.g. array-child navigation).""" + prefs = tool.Blender.get_addon_preferences() + return prefs.decorator_color_unselected[:3], prefs.decorator_color_selected[:3] + + def position_gizmos(self, context: bpy.types.Context) -> None: + raise NotImplementedError( + f"{type(self).__name__} must implement position_gizmos(context) when using BillboardingGizmoGroupMixin." + ) + + +@dataclass(frozen=True) +class IconSlot: + """One slot in a parametric edit gizmo's icon toolbar row. + + The slot's X coordinate is COMPUTED from its index in ``feature_slots`` — + never set explicitly. Adding an icon is a one-line append; the layout + manager resolves the X. Hidden slots STILL CONSUME their X position so + toggling a visibility preference doesn't shift the row. + + Fields: + + - ``gizmo_idname`` — for single-icon slots, the full Blender gizmo + idname. For multi-variant slots, EITHER a string PREFIX that auto- + suffixes ``_`` per member (the common case — e.g. + ``"VIEW3D_GT_lock"`` + variants ``("open", "closed")`` becomes + ``VIEW3D_GT_lock_open`` / ``VIEW3D_GT_lock_closed``) OR a tuple of + explicit idnames matching the variant count when the variants + don't share a prefix. Attributes created on the gizmo group are + ``self._gizmo`` for single slots, ``self.__gizmo`` + for each variant in multi-variant slots. + - ``variants`` — variant suffixes, e.g. ``("open", "closed")`` for a + lock pair, ``("exterior", "center", "interior")`` for a baseline + cycle. Empty tuple = single icon. + - ``color`` — RGB tuple. ``None`` falls back to the gizmo group's default + decoration color. Use the group's ``COLOR_RED`` / ``COLOR_GREEN`` / + ``COLOR_BLUE`` literals for state-coded icons. + - ``extra_gap_before`` — extra spacing past the default uniform gap, in + meters. Use sparingly — e.g. to visually separate a destructive + action (trash) from the routine edit controls. + - ``operator_props`` — tuple of (key, value) pairs forwarded to + ``target_set_operator``'s return value (e.g. ``increment=1`` for a + +/- adjuster, ``property_name="..."`` for a generic toggle). + - ``placeholder`` — when ``True``, the slot reserves an X position in + the row but no auto-managed gizmo is created. Subclasses look the X + up via ``_slot_x_positions()[name]`` to place their own dynamically- + built gizmos (e.g. a live count label). ``gizmo_idname`` / ``operator`` + are unused for placeholders.""" + + name: str + gizmo_idname: str | tuple[str, ...] = "" + operator: str = "" + # Matches DEFAULT_BILLBOARD_SCALE — the scale validate/cancel render at, + # so slots that don't override land at the same visual size by default. + # Helper icons (+/- count adjusters, lock pairs, delete) override with + # smaller values (0.20 - 0.35) to signal secondary affordance. + scale: float = DEFAULT_BILLBOARD_SCALE + color: tuple[float, float, float] | None = None + variants: tuple[str, ...] = () + extra_gap_before: float = 0.0 + operator_props: tuple[tuple[str, Any], ...] = () + placeholder: bool = False + # Optional per-frame visibility predicate. Called with the gizmo group + # instance as the sole argument; returning False hides this slot's gizmo + # while still reserving its X position so the row layout doesn't shift. + # Used for idle-row icons whose relevance depends on element state (e.g. + # toggle_openings only when the host has openings). + visible_when: Optional[Callable[[Any], bool]] = None + + def __post_init__(self) -> None: + # Validate shape at class-definition time so a typo doesn't surface + # as a runtime error in the gizmo group's setup() three layers deep. + if self.placeholder: + return + if self.variants: + if isinstance(self.gizmo_idname, str): + pass # prefix form — idname auto-suffixed per variant + elif isinstance(self.gizmo_idname, tuple) and len(self.gizmo_idname) == len(self.variants): + pass # explicit-tuple form + else: + raise TypeError( + f"IconSlot({self.name!r}): variants={self.variants} requires gizmo_idname " + f"to be either a string prefix (auto-suffixed as _) or a " + f"tuple of {len(self.variants)} explicit idnames, got {self.gizmo_idname!r}" + ) + elif not isinstance(self.gizmo_idname, str) or not self.gizmo_idname: + raise TypeError( + f"IconSlot({self.name!r}): single-icon slot requires gizmo_idname str, " + f"got {self.gizmo_idname!r} (set variants=(...) if you want a multi-variant " + f"slot, or placeholder=True for a reserved-position slot)" + ) + + def variant_idnames(self) -> tuple[str, ...]: + """Resolve per-variant gizmo idnames. For prefix form, suffix each + variant onto the prefix; for tuple form, return as is. Single-icon + slots return a one-element tuple containing the idname.""" + if not self.variants: + assert isinstance(self.gizmo_idname, str) + return (self.gizmo_idname,) + if isinstance(self.gizmo_idname, str): + return tuple(f"{self.gizmo_idname}_{variant}" for variant in self.variants) + return self.gizmo_idname + + def gizmo_attrs(self) -> tuple[str, ...]: + """Names of every ``self.*`` attribute this slot writes during setup. + Returns one for a single slot, N for an N-variant slot, and an empty + tuple for placeholder slots (which reserve X without an auto-gizmo).""" + if self.placeholder: + return () + if self.variants: + return tuple(f"{self.name}_{variant}_gizmo" for variant in self.variants) + return (f"{self.name}_gizmo",) class BaseParametricGizmoGroup: @@ -3988,10 +5303,13 @@ class BaseParametricGizmoGroup: """ # === Gizmo Colors === - # Match Blender axis convention: X=red, Y=green, Z=blue - COLOR_RED = (1.0, 0.2, 0.2) - COLOR_GREEN = (0.1, 0.8, 0.1) - COLOR_BLUE = (0.3, 0.3, 1.0) + # Aliased to the module-level constants so subclass class bodies can + # reference either spelling. Match Blender's axis convention: + # X=red, Y=green, Z=blue. + COLOR_RED = COLOR_RED + COLOR_GREEN = COLOR_GREEN + COLOR_BLUE = COLOR_BLUE + COLOR_NEUTRAL = COLOR_NEUTRAL # === Dimension Gizmo Layout (meters) === ARROW_SCALE = 0.25 # Scale factor for arrow gizmos @@ -4010,14 +5328,111 @@ class BaseParametricGizmoGroup: ICON_VALIDATE_X = 0.0 # X position of validate (checkmark) icon ICON_CANCEL_X = 0.5 # X offset from validate for cancel (X) icon ICON_CYCLE_X = 0.87 # X offset from validate for cycle (arrow) icon + # Subclasses append to declare feature icons in the edit-mode toolbar row. + # The layout manager assigns each slot an X position from its tuple + # index — adding a new icon is a one-line append, no hardcoded X + # constant, no "remember to bump the right edge" rule. The trailing + # ARRAY button is positioned past the last slot automatically. + feature_slots: ClassVar[tuple[IconSlot, ...]] = () + # Idle-mode pen-row extras (e.g. wall's toggle_openings). Each slot is + # placed past the pen at uniform ``ICON_ARRAY_GAP`` spacing. Hidden during + # edit — the validate/cancel row owns the X positions there. Peer gizmo + # groups (e.g. ``GizmoArrayEdition``'s per-layer ARRAY icons) query + # ``_idle_row_right_edge()`` to position past these without a hardcoded + # per-feature table. + idle_slots: ClassVar[tuple[IconSlot, ...]] = () + # Gap between adjacent slots past the leading validate/cancel/cycle + # triplet, AND between the last slot and the ARRAY button. + ICON_ARRAY_GAP: float = 0.37 ICON_Z_OFFSET = 0.5 # Height above element for icons ICON_Y_OFFSET = GIZMO_OFFSET * 2 # Y offset to keep icons clear of geometry + # Offset (meters in world units) used along the screen-up direction when + # world-Z stacking would project to zero on screen (plan / top-down views). + SCREEN_STACK_OFFSET = 0.5 dimension_gizmo_props: list[DimensionGizmoConfig] = [] enable_editing_operator: str = "" finish_editing_operator: str = "" cancel_editing_operator: str = "" + # Mutually exclusive; cycle for 2-4 values, pick for 5+. cycle_type_operator: str = "" + pick_type_operator: str = "" + + REGISTRY: list[type] = [] + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + BaseParametricGizmoGroup.REGISTRY.append(cls) + + @classmethod + def _slot_x_positions(cls) -> dict[str, float]: + """Map each ``feature_slot`` name to its X coordinate in the row. + + Slots are laid out from the cycle position onward at uniform + ``ICON_ARRAY_GAP`` spacing, plus any per-slot ``extra_gap_before``. + When the cycle slot is unused (no ``cycle_type_operator`` / + ``pick_type_operator``), the first feature slot collapses into the + cycle position so the row stays tight — that's how wall's baseline + triplet ends up at X=0.87 without a gap before it. Tuple order is + the only thing that controls X; rearranging the tuple rearranges + the row.""" + positions: dict[str, float] = {} + has_cycle = bool(cls.cycle_type_operator) or bool(cls.pick_type_operator) + next_x = (cls.ICON_CYCLE_X + cls.ICON_ARRAY_GAP) if has_cycle else cls.ICON_CYCLE_X + for slot in cls.feature_slots: + next_x += slot.extra_gap_before + positions[slot.name] = next_x + next_x += cls.ICON_ARRAY_GAP + return positions + + @classmethod + def _feature_row_right_edge(cls) -> float: + """Right edge of the feature icon row, fed to the trailing ARRAY + button's X. Computed strictly from slot order + gaps; empty + ``feature_slots`` collapses to the cycle position.""" + positions = cls._slot_x_positions() + if not positions: + return cls.ICON_CYCLE_X + return max(positions.values()) + + @classmethod + def _idle_slot_x_positions(cls) -> dict[str, float]: + """Map each ``idle_slot`` name to its X coordinate past the pen. + + First idle slot lands at ``ICON_CANCEL_X`` (the cancel-slot position, + unused in idle since validate/cancel are edit-only). Successive slots + are spaced by ``ICON_ARRAY_GAP``, plus any per-slot ``extra_gap_before``.""" + positions: dict[str, float] = {} + next_x = cls.ICON_CANCEL_X + for slot in cls.idle_slots: + next_x += slot.extra_gap_before + positions[slot.name] = next_x + next_x += cls.ICON_ARRAY_GAP + return positions + + @classmethod + def _idle_row_right_edge(cls) -> float: + """Rightmost local-X reserved by this group's idle row. Returns the + pen position (``ICON_VALIDATE_X``) when no idle slots are declared + so peer queries always get a meaningful number.""" + positions = cls._idle_slot_x_positions() + if not positions: + return cls.ICON_VALIDATE_X + return max(positions.values()) + + @classmethod + def pick_visible_anchor(cls, context: bpy.types.Context, world_base: Vector, world_top: Vector) -> Vector: + """Choose between two anchor candidates so vertical separation stays + visible regardless of view orientation. + + In 3D views the world-Z gap between base and top reads cleanly on + screen, so return ``world_top``. In plan / top-down views that gap + projects to zero and the icons stack on each other; return + ``world_base`` lifted along screen-up by ``SCREEN_STACK_OFFSET`` so + the icons stay individually visible and clickable.""" + if tool.Blender.is_view_top_down(context): + return world_base + tool.Blender.get_screen_up_world(context) * cls.SCREEN_STACK_OFFSET + return world_top @classmethod def get_color_from_name(cls, color: GizmoColor | str) -> tuple[float, float, float]: @@ -4084,27 +5499,13 @@ class BaseParametricGizmoGroup: from_neg_y, from_neg_x = self.get_local_view_direction(context, world_matrix) return ViewDirection(from_negative_y=from_neg_y, from_negative_x=from_neg_x) - def update_gizmo_visibility(self, gizmo: bpy.types.Gizmo, is_editing: bool, pref_enabled: bool) -> bool: - """Update gizmo visibility based on modal state, editing state, and preference. - - Consolidates the common pattern: - if hidden_by_modal: - gizmo.hide = True - else: - gizmo.hide = not is_editing or not pref_enabled - - Args: - gizmo: The gizmo to update visibility for - is_editing: Whether the element is currently being edited - pref_enabled: Whether this gizmo type is enabled in preferences - - Returns: - True if the gizmo is now visible (not hidden), False otherwise - """ + def update_gizmo_visibility(self, gizmo: bpy.types.Gizmo, is_editing: bool) -> bool: + """Hide ``gizmo`` when not editing or when a modal owns the viewport. + Returns True if the gizmo is now visible.""" if self.is_gizmo_hidden_by_modal(gizmo): gizmo.hide = True return False - gizmo.hide = not is_editing or not pref_enabled + gizmo.hide = not is_editing return not gizmo.hide def get_y_position_for_view( @@ -4129,6 +5530,32 @@ class BaseParametricGizmoGroup: return width + (self.GIZMO_OFFSET if use_offset else 0) return -self.GIZMO_OFFSET if use_offset else 0 + @staticmethod + def get_camera_facing_outer_y( + viewing_from_negative_y: bool, + near_y: float, + far_y: float, + gizmo_offset: float = 0.0, + ) -> float: + """Y coordinate just outside the camera-facing face of an element. + + Generalises `get_y_position_for_view` for elements whose near face + isn't at the local origin. ``near_y`` is the local-Y of the -Y face; + ``far_y`` is the local-Y of the +Y face. Returns the Y just *outside* the + face the camera is currently looking at, pushed by ``gizmo_offset`` (use + ``cls.GIZMO_OFFSET`` for the standard handle gap). + + Suits walls (``near_y = props.offset``, ``far_y = props.offset + props.thickness``) + and any other element whose section sits inside a non-zero Y band. Stair / + door / window can also call this once their callers pass explicit near/far + instead of the implicit ``width_attr`` pattern, eliminating + ``get_y_position_for_view``, ``get_lining_y_position_for_view`` etc. as + wrappers around the same shape — but they're left intact for now to avoid + churning code paths that already work.""" + if viewing_from_negative_y: + return near_y - gizmo_offset + return far_y + gizmo_offset + def get_icon_y_for_view(self, props, viewing_from_negative_y: bool) -> float: """Get Y position for editing icons based on view direction. @@ -4224,13 +5651,13 @@ class BaseParametricGizmoGroup: """ return 0.0 - def _update_view_dependent_dimensions(self, context: bpy.types.Context, mw: Matrix, props) -> None: + def _update_view_dependent_dimensions(self, context: bpy.types.Context, mw: Matrix, props) -> None: # noqa: ARG002 """Update overall_width, overall_height, and lining_offset based on view direction. This base implementation handles the common pattern for door/window gizmos. Subclasses can override get_casing_offset() to customize behavior. """ - viewing_from_negative_y, viewing_from_negative_x = self.get_local_view_direction(context, mw) + viewing_from_negative_y, viewing_from_negative_x = self._frame_view_dir y_pos = self.get_lining_y_position_for_view(props, viewing_from_negative_y) self.set_dimension_gizmo_position("overall_width", mw, Vector((0, y_pos, -self.GIZMO_OFFSET)), (1, 0, 0)) @@ -4249,22 +5676,13 @@ class BaseParametricGizmoGroup: gizmo_type: str, color: tuple[float, float, float], operator: str, - prop_path: str | None = None, alpha: float = 0.8, **operator_props, ) -> bpy.types.Gizmo: """Create an icon gizmo with common settings. - Args: - gizmo_type: Blender gizmo type (e.g., "VIEW3D_GT_lock", "VIEW3D_GT_plus") - color: RGB color tuple - operator: Operator ID to trigger (e.g., "bim.toggle_stair_property") - prop_path: Optional property path for lock icons (e.g., "BIMStairProperties.lock") - alpha: Opacity (default 0.8) - **operator_props: Additional operator properties to set - - Returns: - The created gizmo + State-aware icons must use a static pair (open/closed) and have the + consumer pick which one to show. """ prefs = tool.Blender.get_addon_preferences() highlight_color = prefs.decorator_color_selected[:3] @@ -4274,8 +5692,6 @@ class BaseParametricGizmoGroup: gz.color = color gz.color_highlight = highlight_color gz.alpha = alpha - if prop_path: - gz.prop_path = prop_path op = gz.target_set_operator(operator) for key, value in operator_props.items(): setattr(op, key, value) @@ -4285,23 +5701,30 @@ class BaseParametricGizmoGroup: self, color: tuple[float, float, float], operator: str, - prop_path: str | None = None, alpha: float = 0.5, **operator_props, ) -> bpy.types.Gizmo: - """Create an arc gizmo for swing/rotation indicators (e.g., door swing). + return self.create_icon_gizmo("VIEW3D_GT_arc", color, operator, alpha, **operator_props) - Args: - color: RGB color tuple - operator: Operator ID to trigger (e.g., "bim.toggle_door_swing") - prop_path: Optional property path (e.g., "BIMDoorProperties.door_type") - alpha: Opacity (default 0.5 for arc gizmos) - **operator_props: Additional operator properties to set + def create_icon_gizmo_lock_pair( + self, + operator: str, + open_color: tuple[float, float, float], + closed_color: tuple[float, float, float] | None = None, + alpha: float = 0.8, + **operator_props, + ) -> tuple[bpy.types.Gizmo, bpy.types.Gizmo]: + """Create an open/closed padlock gizmo pair sharing one operator binding. - Returns: - The created arc gizmo - """ - return self.create_icon_gizmo("VIEW3D_GT_arc", color, operator, prop_path, alpha, **operator_props) + ``closed_color`` defaults to ``open_color`` for neutral pairs. Caller + hides whichever member is inappropriate for the current state, then + positions both together via ``set_icon_gizmo_pair_position`` so a + state flip can't reveal a stale pose.""" + if closed_color is None: + closed_color = open_color + open_gz = self.create_icon_gizmo("VIEW3D_GT_lock_open", open_color, operator, alpha, **operator_props) + closed_gz = self.create_icon_gizmo("VIEW3D_GT_lock_closed", closed_color, operator, alpha, **operator_props) + return open_gz, closed_gz @classmethod def is_element_type(cls, element) -> bool: @@ -4309,22 +5732,52 @@ class BaseParametricGizmoGroup: @classmethod def poll(cls, context) -> bool: - prefs = tool.Blender.get_addon_preferences() - if not prefs.gizmos.draw_gizmos_in_3d_viewport: - return False - obj = tool.Blender.get_active_object(is_selected=True) - if not obj: + if obj is None: return False + if not tool.Blender.are_viewport_gizmos_enabled(): + return False + # Hide every parametric gizmo while any preview is open — the preview + # is the only interactive surface in that mode, sister gizmos would + # compete for screen space and let the user trigger mutations that + # would race the preview's in-progress draft. + from bonsai.bim.module.model import preview_base + if preview_base.any_preview_active(context): + return False + if _is_transform_modal_active(context): + return False + if cls.gizmo_pref_name: + prefs = tool.Blender.get_addon_preferences() + if not getattr(prefs.gizmos, cls.gizmo_pref_name, True): + return False if len(tool.Blender.get_selected_objects()) != 1: return False - element = tool.Ifc.get_entity(obj) - if not element or not cls.is_element_type(element): + if not element: + return False + # Array children are managed replicas — their parametric attributes get + # overwritten on the next ``regenerate_array``, so editing them via the + # parametric gizmos would be silently undone. Skip across every gizmo + # group (door/window/stair/wall/roof/railing/array all inherit this poll). + if tool.Blender.Modifier.is_array_child(element): + return False + if not cls.is_element_type(element): + return False + # Mutual exclusion between parametric and array edit lifecycles — running two + # finish operators against the same object would race, and the doubled + # validate/cancel icon stack reads as a UI bug. Hide this gizmo group + # while a different parametric type is in an active edit lifecycle on obj. + if cls._other_parametric_edit_active(obj): return False return True + @classmethod + def _other_parametric_edit_active(cls, obj: bpy.types.Object) -> bool: + """True if any parametric type OTHER than this group's own is in an + active edit lifecycle on ``obj``.""" + return tool.Parametric.is_object_editing(obj, skip_name=getattr(cls, "gizmo_pref_name", None)) is not None + def setup(self, context: bpy.types.Context) -> None: """Template method for gizmo setup. @@ -4343,6 +5796,19 @@ class BaseParametricGizmoGroup: """ pass + # Frame-scoped caches primed at the top of ``refresh()`` and ``draw_prepare()``. + # Every per-frame helper — preferences access, view-direction lookup, billboard + # rotation — reads these instead of re-deriving the same values, since each + # gizmo group ends up needing them 2–5× per frame across its position helpers. + _frame_prefs: Any = None + _frame_view_dir: tuple[bool, bool] | None = None + _frame_billboard_rot: "Matrix | None" = None + + def _prime_frame_caches(self, context: bpy.types.Context, mw: "Matrix") -> None: + self._frame_prefs = tool.Blender.get_addon_preferences() + self._frame_view_dir = self.get_local_view_direction(context, mw) + self._frame_billboard_rot = get_billboard_rotation(context) + def refresh(self, context: bpy.types.Context) -> None: """Template method for gizmo refresh. @@ -4357,6 +5823,7 @@ class BaseParametricGizmoGroup: props = self.get_props(obj) mw = obj.matrix_world + self._prime_frame_caches(context, mw) self.update_editing_gizmos(context, mw, props) self.update_dimension_gizmos(mw, props) self._refresh_element_specific(context, mw, props) @@ -4364,31 +5831,40 @@ class BaseParametricGizmoGroup: def _refresh_element_specific(self, context: bpy.types.Context, mw: "Matrix", props) -> None: # noqa: ARG002 """Override for element-specific refresh logic. - Called after update_editing_gizmos and update_dimension_gizmos. - Examples: door swing gizmos, stair lock/tread/plus/minus gizmos. + Called from both refresh() (on state change) and draw_prepare() (per frame), + so any override must be idempotent and cheap. Use this to re-position or + re-billboard element-specific gizmos (door swing arcs, stair lock/+/- icons, + wall cursor icons, etc.). """ pass - # Subclass should define these class attributes for metadata-driven dispatch - # If not defined, subclass must override get_props() and get_gizmo_prefs() - props_getter: str | None = None # e.g., "get_door_props" + # Subclass should define these class attributes for metadata-driven dispatch. + # ``gizmo_pref_name`` matches a flat BoolProperty field on + # ``GizmoPreferences`` and gates the whole gizmo group's poll. + props_getter: Callable[[bpy.types.Object], bpy.types.PropertyGroup] | None = None gizmo_pref_name: str | None = None # e.g., "door" def get_props(self, obj: bpy.types.Object) -> Any: """Get properties for the element. Subclass can either: - 1. Define class attribute `props_getter` (e.g., "get_door_props") + 1. Define class attribute `props_getter` (e.g., tool.Model.get_door_props) 2. Override this method directly + + The ``props_getter`` reference is captured at class-definition time + (early binding), so tests cannot redirect it via + ``patch.object(tool.Model, "get_X_props", ...)``. Inject a stub + callable directly when exercising dispatch in tests. """ if self.props_getter: - return getattr(tool.Model, self.props_getter)(obj) + return self.props_getter(obj) raise NotImplementedError("Subclass must define props_getter or override get_props()") - @staticmethod - def get_addon_prefs(): - """Get addon preferences (cached accessor).""" - return tool.Blender.get_addon_preferences() + def get_addon_prefs(self): + """Return the addon preferences struct. Inside ``refresh`` / ``draw_prepare`` + the frame cache is hit; outside (e.g. ``setup``) we fall through to a fresh + lookup so callers don't have to know which call path they're on.""" + return self._frame_prefs if self._frame_prefs is not None else tool.Blender.get_addon_preferences() def get_decoration_colors(self) -> tuple[tuple[float, float, float], tuple[float, float, float]]: """Get default and highlight colors from preferences. @@ -4399,18 +5875,6 @@ class BaseParametricGizmoGroup: prefs = self.get_addon_prefs() return prefs.decorations_colour[:3], prefs.decorator_color_selected[:3] - def get_gizmo_prefs(self) -> Any: - """Get gizmo preferences for this element type. - - Subclass can either: - 1. Define class attribute `gizmo_pref_name` (e.g., "door") - 2. Override this method directly - """ - if self.gizmo_pref_name: - prefs = self.get_addon_prefs() - return getattr(prefs.gizmos, self.gizmo_pref_name) - raise NotImplementedError("Subclass must define gizmo_pref_name or override get_gizmo_prefs()") - def is_setup_complete(self) -> bool: """Check if gizmo setup has been completed. @@ -4495,20 +5959,33 @@ class BaseParametricGizmoGroup: y: float, z: float, billboard_rot: Matrix, - scale: float = 0.5, + scale: float = DEFAULT_BILLBOARD_SCALE, ) -> None: - """Set an icon gizmo's position with billboard rotation. - - Args: - gizmo_name: The gizmo attribute name (e.g., "validate_gizmo") - mw: Object's world matrix - x, y, z: Local position coordinates - billboard_rot: Billboard rotation matrix to face camera - scale: Gizmo scale factor (default 0.5) - """ if gz := self.get_gizmo_if_visible(gizmo_name): - local_transform = Matrix.Translation(Vector((x, y, z))) @ billboard_rot @ Matrix.Scale(scale, 4) - gz.matrix_basis = mw @ local_transform + world_pos = mw @ Vector((x, y, z)) + gz.matrix_basis = billboarded_at(world_pos, billboard_rot, scale) + + def set_icon_gizmo_pair_position( + self, + open_name: str, + closed_name: str, + mw: Matrix, + x: float, + y: float, + z: float, + billboard_rot: Matrix, + scale: float = DEFAULT_BILLBOARD_SCALE, + ) -> None: + """Position both members of an open/closed pair at the same anchor; + write the matrix on both so a state flip can't reveal a stale pose.""" + open_gz = getattr(self, open_name, None) + closed_gz = getattr(self, closed_name, None) + if not open_gz or not closed_gz: + return + world_pos = mw @ Vector((x, y, z)) + matrix = billboarded_at(world_pos, billboard_rot, scale) + open_gz.matrix_basis = matrix + closed_gz.matrix_basis = matrix def set_dimension_gizmo_position( self, @@ -4554,30 +6031,13 @@ class BaseParametricGizmoGroup: else: gizmo.matrix_basis = mw @ base_matrix - def should_hide_dimension_gizmo( - self, gizmo: bpy.types.Gizmo, config: "DimensionGizmoConfig", props, gizmo_prefs - ) -> bool: - """Unified visibility check for dimension gizmos. - - Checks all hide conditions in priority order: - 1. Modal operator hiding - 2. User preference visibility toggle - 3. Editing state - 4. Custom visibility condition from config - - Args: - gizmo: The gizmo to check - config: Dimension gizmo configuration - props: Element properties object - gizmo_prefs: Gizmo visibility preferences - - Returns: - True if gizmo should be hidden, False otherwise - """ + def should_hide_dimension_gizmo(self, gizmo: bpy.types.Gizmo, config: "DimensionGizmoConfig", props) -> bool: + """Hide a dimension gizmo when its modal owner is active, when the + element isn't in edit state for this attribute, or when the config + carries a custom visibility predicate that rejects ``props``. The + per-feature enable toggle is gated upstream by ``poll()``.""" if self.is_gizmo_hidden_by_modal(gizmo): return True - if not getattr(gizmo_prefs, config.attr_name, True): - return True if self.should_hide_gizmo(config.attr_name, props): return True if config.visibility_condition and not config.visibility_condition(props): @@ -4594,35 +6054,30 @@ class BaseParametricGizmoGroup: ) -> bpy.types.Gizmo: """Create and configure an icon gizmo with standard settings. - Reduces boilerplate in setup_editing_gizmos. - - Args: - gizmo_type: Blender gizmo type identifier (e.g., "VIEW3D_GT_pen") - color: RGB color tuple - operator: Operator to invoke on click - highlight_color: Optional highlight color (defaults to prefs selection color) - alpha: Gizmo alpha (default 0.8) - - Returns: - Configured gizmo instance. + Thin wrapper over `setup_icon_gizmo` that defaults ``highlight_color`` + to the addon-prefs selection color via ``get_decoration_colors``. """ if highlight_color is None: _, highlight_color = self.get_decoration_colors() - - gizmo = self.gizmos.new(gizmo_type) - gizmo.use_draw_scale = False - gizmo.color = color - gizmo.color_highlight = highlight_color - gizmo.alpha = alpha - gizmo.target_set_operator(operator) - return gizmo + return setup_icon_gizmo(self, gizmo_type, color, highlight_color, operator, alpha) def setup_editing_gizmos(self, context: bpy.types.Context) -> None: default_color, highlight_color = self.get_decoration_colors() - self.pen_gizmo = self._setup_icon_gizmo( - "VIEW3D_GT_pen", default_color, self.enable_editing_operator, highlight_color - ) + # Pen icon is bound to ``bim.enable_editing_parametric`` (a universal dispatcher) + # rather than the gizmo group's own enable op directly. The dispatcher receives + # this group's ``enable_editing_operator`` as ``feature_enable_op`` and: + # - plain click → fires the per-feature enable (this group's operator) + # - Shift+click → fires ``bim.enable_editing_array`` if the active element is + # an array parent (one pen icon, two behaviours; no second pen needed for arrays). + self.pen_gizmo = self.gizmos.new("VIEW3D_GT_pen") + self.pen_gizmo.use_draw_scale = False + self.pen_gizmo.color = default_color + self.pen_gizmo.color_highlight = highlight_color + self.pen_gizmo.alpha = 0.8 + pen_op = self.pen_gizmo.target_set_operator("bim.enable_editing_parametric") + pen_op.feature_enable_op = self.enable_editing_operator + self.validate_gizmo = self._setup_icon_gizmo( "VIEW3D_GT_validate", self.COLOR_GREEN, self.finish_editing_operator, highlight_color ) @@ -4630,10 +6085,59 @@ class BaseParametricGizmoGroup: "VIEW3D_GT_cancel", self.COLOR_RED, self.cancel_editing_operator, highlight_color ) + # Type-selector slot: cycle (one click advances) or pick (popup menu). + # ``self.cycle_gizmo`` is the shared instance name regardless of icon — + # consumers reposition / hide it via that attribute. ``cycle_type_operator`` + # wins if both are set (consumers shouldn't set both). if self.cycle_type_operator: self.cycle_gizmo = self._setup_icon_gizmo( "VIEW3D_GT_cycle", default_color, self.cycle_type_operator, highlight_color ) + elif self.pick_type_operator: + self.cycle_gizmo = self._setup_icon_gizmo( + "VIEW3D_GT_menu", default_color, self.pick_type_operator, highlight_color + ) + + # Feature-specific edit-row icons. Subclasses declare them via + # ``feature_slots``; multi-variant slots create one gizmo per + # variant at the same X (e.g. a lock pair, a baseline triplet) and + # the subclass picks which is visible per frame. Placeholder slots + # only reserve an X position — the subclass creates its own gizmo + # there in ``setup_element_specific_gizmos``. + for slot in self.feature_slots: + if slot.placeholder: + continue + slot_color = slot.color if slot.color is not None else default_color + kwargs = dict(slot.operator_props) + for attr, idname in zip(slot.gizmo_attrs(), slot.variant_idnames()): + gz = self.create_icon_gizmo(idname, slot_color, slot.operator, **kwargs) + setattr(self, attr, gz) + + # Idle-mode pen-row extras. Same creation path as feature_slots; the + # IDLE branch of ``update_editing_gizmos`` positions and visibility- + # gates them, the EDIT branch hides them so the validate/cancel row + # owns the X positions. + for slot in self.idle_slots: + if slot.placeholder: + continue + slot_color = slot.color if slot.color is not None else default_color + kwargs = dict(slot.operator_props) + for attr, idname in zip(slot.gizmo_attrs(), slot.variant_idnames()): + gz = self.create_icon_gizmo(idname, slot_color, slot.operator, **kwargs) + setattr(self, attr, gz) + + # ARRAY button — visible during the feature edit lifecycle only (positioned by + # ``update_editing_gizmos``). Click commits the current edit and adds a + # Blender-vanilla-defaulted array (count=2, X-offset = bbox extent). The + # array gizmo group opts out via ``hide_array_button = True`` since + # adding an array to an array layer is the panel's job, not a gizmo's. + if not getattr(self, "hide_array_button", False): + self.array_gizmo = self._setup_icon_gizmo( + "VIEW3D_GT_array_all", + default_color, + "bim.add_array_from_feature_edit", + highlight_color, + ) def _make_dimension_getter(self, config: DimensionGizmoConfig): """Create getter closure for dimension gizmo.""" @@ -4659,15 +6163,16 @@ class BaseParametricGizmoGroup: return move_get def _make_dimension_setter(self, config: DimensionGizmoConfig): - """Create setter closure for dimension gizmo.""" + """Setter closure. ``min_value`` clamps only on the default + ``attr_name`` path; a custom ``apply_value`` owns its own bounding.""" if config.apply_value: - apply_fn, min_val = config.apply_value, config.min_value + apply_fn = config.apply_value def move_set(value): obj = bpy.context.active_object if not obj: return - apply_fn(self.get_props(obj), max(min_val, value)) + apply_fn(self.get_props(obj), value) return move_set @@ -4681,12 +6186,78 @@ class BaseParametricGizmoGroup: return move_set + # Fixed visual length (world units) for count gizmos. Decoupled from the + # underlying integer value so a count of 99 doesn't render as a 99-metre bar. + COUNT_GIZMO_VISUAL_LENGTH = 0.3 + + def _make_count_setter(self, config: "CountGizmoConfig"): + """Create setter closure for count gizmo. Snaps to integer step and + clamps to [min_count, max_count] before applying.""" + min_count, max_count, step = config.min_count, config.max_count, config.step + + if config.apply_value: + apply_fn = config.apply_value + + def move_set(value): + obj = bpy.context.active_object + if not obj: + return + snapped = max(min_count, min(max_count, int(round(value / step)) * step)) + apply_fn(self.get_props(obj), snapped) + + return move_set + + attr_name = config.attr_name + + def move_set(value): + obj = bpy.context.active_object + if not obj: + return + snapped = max(min_count, min(max_count, int(round(value / step)) * step)) + setattr(self.get_props(obj), attr_name, snapped) + + return move_set + + def _setup_count_gizmo(self, config: "CountGizmoConfig", highlight_color: tuple[float, float, float]) -> None: + """Configure a BIM_GT_gizmo_dimension instance to behave as an integer stepper. + + Reuses the dimension gizmo type — only configuration differs (no arrows, + no extension lines, int-snapped setter, count_formatter as text_formatter, + fixed visual length applied per-frame in ``update_dimension_gizmos``).""" + gizmo = self.gizmos.new("BIM_GT_gizmo_dimension") + gizmo.move_get_cb = self._make_dimension_getter(config) + gizmo.move_set_cb = self._make_count_setter(config) + gizmo.axis = Vector(config.axis) + gizmo.local_axis = Vector(config.axis) + gizmo.invert_delta = False + gizmo.delta_scale = config.delta_scale + gizmo.prop_name = config.prop_name + gizmo.gizmo_group = self + # Count formatter receives (props, value) like text_formatter; the + # dimension gizmo's draw path calls it once per frame. + gizmo.text_formatter = config.count_formatter or (lambda props, value: str(int(value))) + gizmo.color = self.get_color_from_name(config.color) + gizmo.color_highlight = highlight_color + gizmo.alpha = 1.0 + gizmo.use_draw_modal = True + gizmo.use_draw_scale = False + gizmo.text_offset_sign = 1 + gizmo.text_alignment = TextAlignment.CENTER + # Count visual is a plain bar — no arrows, no extension lines. + gizmo.show_start_arrow = False + gizmo.show_end_arrow = False + gizmo.show_extension_lines = False + setattr(self, f"dimension_{config.attr_name}_gizmo", gizmo) + def setup_dimension_gizmos(self, context: bpy.types.Context) -> None: - """Set up dimension gizmos from dimension_gizmo_props configuration.""" + """Set up value gizmos (dimensions and counts) from dimension_gizmo_props.""" prefs = tool.Blender.get_addon_preferences() highlight_color = prefs.decorator_color_selected[:3] for config in getattr(self, "dimension_gizmo_props", []): + if isinstance(config, CountGizmoConfig): + self._setup_count_gizmo(config, highlight_color) + continue gizmo = self.gizmos.new("BIM_GT_gizmo_dimension") gizmo.move_get_cb = self._make_dimension_getter(config) gizmo.move_set_cb = self._make_dimension_setter(config) @@ -4696,6 +6267,7 @@ class BaseParametricGizmoGroup: gizmo.delta_scale = config.delta_scale gizmo.prop_name = config.prop_name # Auto-derived in __post_init__ gizmo.gizmo_group = self + gizmo.text_formatter = config.text_formatter gizmo.color = self.get_color_from_name(config.color) gizmo.color_highlight = highlight_color gizmo.alpha = 1.0 @@ -4708,25 +6280,21 @@ class BaseParametricGizmoGroup: setattr(self, f"dimension_{config.attr_name}_gizmo", gizmo) def update_dimension_gizmos(self, mw: Matrix, props) -> None: - """Update dimension gizmos from dimension_gizmo_props configuration.""" - gizmo_prefs = self.get_gizmo_prefs() - + """Update value gizmos (dimensions and counts) from dimension_gizmo_props.""" for config in getattr(self, "dimension_gizmo_props", []): gizmo = getattr(self, f"dimension_{config.attr_name}_gizmo", None) if gizmo is None: continue - # Use unified visibility checker - if self.should_hide_dimension_gizmo(gizmo, config, props, gizmo_prefs): + if self.should_hide_dimension_gizmo(gizmo, config, props): gizmo.hide = True continue gizmo.hide = False - # Priority: config.matrix_position > get_dimension_matrix_* method > Identity + # Priority: config.matrix_position > get_dimension_matrix_* method > Identity. if config.matrix_position: - position = config.matrix_position(props) - base_matrix = self.compose_gizmo_matrix(position, config.axis) + base_matrix = self.compose_gizmo_matrix(config.matrix_position(props), config.axis) else: matrix_method = getattr(self, f"get_dimension_matrix_{config.attr_name}", None) base_matrix = matrix_method(props) if matrix_method else Matrix.Identity(4) @@ -4736,6 +6304,15 @@ class BaseParametricGizmoGroup: else: value = getattr(props, config.attr_name, 0.0) + if isinstance(config, CountGizmoConfig): + # Visual length is decoupled from the integer count — the bar + # stays at a constant world size while the label tracks ``value``. + gizmo.matrix_basis = mw @ base_matrix + gizmo._dimension_length = self.COUNT_GIZMO_VISUAL_LENGTH + gizmo._display_value = value + gizmo.select_bias = -self.COUNT_GIZMO_VISUAL_LENGTH + continue + # Use consolidated negative value handling self._apply_dimension_matrix(gizmo, mw, base_matrix, value) gizmo.show_start_arrow = config.show_start_arrow @@ -4758,7 +6335,7 @@ class BaseParametricGizmoGroup: """ return (0.0, 0.0) - def get_icon_y_offset(self, context: bpy.types.Context, mw: Matrix) -> float: + def get_icon_y_offset(self, context: bpy.types.Context, mw: Matrix) -> float: # noqa: ARG002 """Get Y offset for icons based on view direction. Uses get_icon_y_extent() to determine how far to offset icons based on @@ -4774,8 +6351,7 @@ class BaseParametricGizmoGroup: props = self.get_props(obj) positive_extent, negative_extent = self.get_icon_y_extent(props) - viewing_from_negative_y, _ = self.get_local_view_direction(context, mw) - if viewing_from_negative_y: + if self._frame_view_dir[0]: return -negative_extent return positive_extent @@ -4783,52 +6359,175 @@ class BaseParametricGizmoGroup: """Update editing icon gizmo positions to billboard toward camera.""" icon_z = self.get_element_height(props) + self.ICON_Z_OFFSET icon_y = self.get_icon_y_offset(context, mw) - billboard_rot = get_billboard_rotation(context) - - # This ensures icons face camera regardless of object rotation - local_pos_validate = Vector((self.ICON_VALIDATE_X, icon_y, icon_z)) - world_pos_validate = mw @ local_pos_validate - - icon_matrix_base = Matrix.Translation(world_pos_validate) @ billboard_rot @ Matrix.Scale(0.5, 4) - + billboard_rot = self._frame_billboard_rot + # set_icon_gizmo_position no-ops on hidden gizmos (via get_gizmo_if_visible), + # so the hide flag must be set first; that gates whether the matrix is written. if props.is_editing: self.pen_gizmo.hide = True self.validate_gizmo.hide = self.is_gizmo_hidden_by_modal(self.validate_gizmo) - self.validate_gizmo.matrix_basis = icon_matrix_base - + self.set_icon_gizmo_position( + "validate_gizmo", mw=mw, x=self.ICON_VALIDATE_X, y=icon_y, z=icon_z, billboard_rot=billboard_rot + ) self.cancel_gizmo.hide = self.is_gizmo_hidden_by_modal(self.cancel_gizmo) - local_pos_cancel = Vector((self.ICON_VALIDATE_X + self.ICON_CANCEL_X, icon_y, icon_z)) - world_pos_cancel = mw @ local_pos_cancel - self.cancel_gizmo.matrix_basis = Matrix.Translation(world_pos_cancel) @ billboard_rot @ Matrix.Scale(0.5, 4) - - if self.cycle_type_operator: + self.set_icon_gizmo_position( + "cancel_gizmo", + mw=mw, + x=self.ICON_VALIDATE_X + self.ICON_CANCEL_X, + y=icon_y, + z=icon_z, + billboard_rot=billboard_rot, + ) + if self.cycle_type_operator or self.pick_type_operator: self.cycle_gizmo.hide = self.is_gizmo_hidden_by_modal(self.cycle_gizmo) - local_pos_cycle = Vector((self.ICON_VALIDATE_X + self.ICON_CYCLE_X, icon_y, icon_z)) - world_pos_cycle = mw @ local_pos_cycle - self.cycle_gizmo.matrix_basis = ( - Matrix.Translation(world_pos_cycle) @ billboard_rot @ Matrix.Scale(0.30, 4) + self.set_icon_gizmo_position( + "cycle_gizmo", + mw=mw, + x=self.ICON_VALIDATE_X + self.ICON_CYCLE_X, + y=icon_y, + z=icon_z, + billboard_rot=billboard_rot, + scale=0.30, ) + # Feature slots: per-class IconSlot tuples driven by tuple order. + # Whole-feature visibility is gated upstream by ``poll()`` against + # ``prefs.gizmos.``; positioning happens unconditionally + # whenever the gizmo group polls visible. + slot_positions = self._slot_x_positions() + for slot in self.feature_slots: + if slot.placeholder: + continue + slot_x = self.ICON_VALIDATE_X + slot_positions[slot.name] + attrs = slot.gizmo_attrs() + if slot.variants: + # Multi-variant slot: write matrix on every variant member + # at the same anchor so a state flip never reveals a stale + # pose. The subclass's per-frame hook picks which member + # is visible — this loop doesn't toggle hide flags. + world_pos = mw @ Vector((slot_x, icon_y, icon_z)) + matrix = billboarded_at(world_pos, billboard_rot, scale=slot.scale) + for attr in attrs: + gz = getattr(self, attr, None) + if gz is not None: + gz.matrix_basis = matrix + continue + gz = getattr(self, attrs[0], None) + if gz is None: + continue + gz.hide = self.is_gizmo_hidden_by_modal(gz) + self.set_icon_gizmo_position( + attrs[0], + mw=mw, + x=slot_x, + y=icon_y, + z=icon_z, + billboard_rot=billboard_rot, + scale=slot.scale, + ) + # ARRAY button sits past the last feature-specific icon. Slot-based + # subclasses derive the right edge from the slot count. + if hasattr(self, "array_gizmo"): + self.array_gizmo.hide = self.is_gizmo_hidden_by_modal(self.array_gizmo) + # 30% smaller than the editing-icon-row default (0.50 → 0.35): + # the array button is a tertiary affordance compared to the + # primary pen / validate / cancel triad, and the smaller + # footprint keeps the edit-mode row from sprawling. + self.set_icon_gizmo_position( + "array_gizmo", + mw=mw, + x=self.ICON_VALIDATE_X + self._feature_row_right_edge() + self.ICON_ARRAY_GAP, + y=icon_y, + z=icon_z, + billboard_rot=billboard_rot, + scale=0.35, + ) + # Idle-row icons are hidden in edit — validate / cancel sit at + # the same X positions, so showing both would stack icons. + for slot in self.idle_slots: + for attr in slot.gizmo_attrs(): + gz = getattr(self, attr, None) + if gz is not None: + gz.hide = True else: - self.pen_gizmo.hide = self.is_gizmo_hidden_by_modal(self.pen_gizmo) - self.pen_gizmo.matrix_basis = icon_matrix_base + # ``hide_pen_button = True`` keeps the pen permanently hidden — for + # groups whose edit-mode entry is already provided by another widget + # in the same viewport region. ``GizmoArrayEdition`` opts in because + # its clickable ``xN`` count label (``GizmoArrayCount``) is the + # canonical entry point; surfacing a second pen next to it is the + # redundant icon the user saw in the array gizmo viewport. + if getattr(self, "hide_pen_button", False): + self.pen_gizmo.hide = True + else: + self.pen_gizmo.hide = self.is_gizmo_hidden_by_modal(self.pen_gizmo) + self.set_icon_gizmo_position( + "pen_gizmo", mw=mw, x=self.ICON_VALIDATE_X, y=icon_y, z=icon_z, billboard_rot=billboard_rot + ) self.validate_gizmo.hide = True self.cancel_gizmo.hide = True - if self.cycle_type_operator: + if self.cycle_type_operator or self.pick_type_operator: self.cycle_gizmo.hide = True + for slot in self.feature_slots: + for attr in slot.gizmo_attrs(): + gz = getattr(self, attr, None) + if gz is not None: + gz.hide = True + if hasattr(self, "array_gizmo"): + self.array_gizmo.hide = True + # Idle slots: position past the pen, apply per-slot visible_when + # so state-dependent icons (e.g. toggle_openings) only render + # when relevant. Hidden slots STILL consume their X position so + # the row layout doesn't shift when state flips. + idle_positions = self._idle_slot_x_positions() + for slot in self.idle_slots: + if slot.placeholder: + continue + slot_x = self.ICON_VALIDATE_X + idle_positions[slot.name] + gate = slot.visible_when + visible = True if gate is None else bool(gate(self)) + for attr in slot.gizmo_attrs(): + gz = getattr(self, attr, None) + if gz is None: + continue + if not visible: + gz.hide = True + continue + gz.hide = self.is_gizmo_hidden_by_modal(gz) + self.set_icon_gizmo_position( + attr, + mw=mw, + x=slot_x, + y=icon_y, + z=icon_z, + billboard_rot=billboard_rot, + scale=slot.scale, + ) def draw_prepare(self, context: bpy.types.Context) -> None: """Called before drawing - updates gizmos to face camera. - This method updates editing gizmos and dimension gizmos. - Subclasses can override _update_dimension_gizmo_positions() to customize - dimension gizmo positioning based on view direction. + This method updates editing gizmos, dimension gizmos, and element-specific + gizmos. Subclasses can override _update_dimension_gizmo_positions() to + customize dimension gizmo positioning, and _refresh_element_specific() to + re-billboard element-specific gizmos per frame. """ + if not self.is_setup_complete(): + return + if apply_transform_modal_draw_gate(self, context): + return obj = context.active_object if not obj: return props = self.get_props(obj) mw = obj.matrix_world + self._prime_frame_caches(context, mw) self.update_editing_gizmos(context, mw, props) + # `update_dimension_gizmos` flips the dimension gizmos' `hide` flag + # based on `props.is_editing` + per-config visibility conditions. + # `refresh()` already calls it, but `refresh()` only fires on depsgraph + # events — a `finish_editing_*` operator that toggles `is_editing` to + # False without mutating IFC (e.g. wall no-op commit, cancel) does not + # trigger a depsgraph update, so without this call the dimension gizmos + # would stay visible until the next user input. + self.update_dimension_gizmos(mw, props) self._update_dimension_gizmo_positions(context, mw, props) @@ -4836,6 +6535,8 @@ class BaseParametricGizmoGroup: for _, gizmo in self.iter_visible_dimension_gizmos(): gizmo.draw_prepare(context) + self._refresh_element_specific(context, mw, props) + def _update_dimension_gizmo_positions( self, context: bpy.types.Context, mw: "Matrix", props # noqa: ARG002 ) -> None: @@ -4849,3 +6550,619 @@ class BaseParametricGizmoGroup: mw: Object's world matrix props: Element properties object """ + + +class BaseSchematicGizmoGroup(BaseParametricGizmoGroup): + """Base for parametric gizmo groups that drive a billboarded schematic preview. + + Provides: + + - Schematic-anchored ``BIM_GT_gizmo_dimension`` instances declared via + ``schematic_dimension_props``. Each dimension is laid out in + schematic-local coordinates around the schematic anchor and + billboarded to the camera, so the labelled tag reads the same size + regardless of the bound value and the camera angle. + - A GPU draw handler that renders a live mini preview of the element's + geometry near the icon row. Subclasses build the bmesh in + ``build_schematic_mesh(props)`` and the handler reuses a cached + list of local-coordinate edge pairs across redraws. + + Subclasses leave ``dimension_gizmo_props = []`` (the default here) and + populate ``schematic_dimension_props`` instead. The pen / validate / + cancel / cycle icon row inherited from the parametric base still applies. + + Decoration-only: the preview mesh is not hit-testable; clicks land on + the labelled dimensions, which carry the parametric edit semantics. + """ + + # Schematic groups don't draw in-place dimension lines; the parent's + # setup_dimension_gizmos / update_dimension_gizmos iterate this empty + # list and become no-ops. The schematic equivalents below take their place. + dimension_gizmo_props: list[DimensionGizmoConfig] = [] + + # Declarative dimension configuration consumed by ``setup_schematic_dimensions`` + # and ``update_schematic_dimensions``. Each config produces one + # ``BIM_GT_gizmo_dimension`` instance positioned at a schematic-local + # location and billboarded to the camera. The dimension's *visual* length + # is the actual value rescaled into schematic units via + # ``_compute_schematic_scale`` and floored at a minimum visible length, + # so tiny dimensions stay grabable; the *displayed* numeric label still + # shows the real value via ``text_formatter``. + schematic_dimension_props: list[DimensionGizmoConfig] = [] + + # World-unit half-extent of the schematic decoration box anchored at the + # icon row. Sliders' ``slider_position`` values are interpreted inside + # this box; subclasses scale ``build_schematic_mesh`` output to fit it. + schematic_box_size: float = 0.3 + + # Offset from the icon-row anchor (object origin + element height + + # ICON_Z_OFFSET) to the bottom-centre of the schematic, applied as + # ``billboard_rot @ schematic_anchor_offset``. The coordinate convention + # matches ``billboard_rot``: schematic-local +X → screen RIGHT, +Y → screen + # UP, +Z → toward the viewer. The default ``(0, 0.9, 0)`` lifts the + # schematic by 0.9 world-units in screen UP so it clears the validate / + # cancel icons (which sit at the icon-row anchor with scale 0.2). + schematic_anchor_offset: Vector = Vector((0.0, 0.9, 0.0)) + + # Fixed rotation applied to the schematic frame *before* billboarding, + # so the schematic appears at the same tilt regardless of camera angle. + # Default identity ⇒ flat front view. Subclasses can set a small + # rotation (e.g. ~25° around Y) to expose the depth axis, so dimensions + # along schematic-local Z have a visible on-screen extent. Useful when + # one of the bound properties is a depth/thickness whose true geometric + # direction is otherwise invisible from a flat front-facing schematic. + schematic_view_rotation: "Matrix" = Matrix.Identity(4) + + # Per-concrete-subclass draw-handler singleton. Python writes via + # ``cls._draw_handler_installed = ...`` land on the concrete class + # (not on this base), so two consumer subclasses do not collide. + _draw_handler_installed: object | None = None + + # Per-concrete-subclass cache of (schematic_cache_key → list[(Vector, + # Vector, tag)]) — schematic-local edge endpoints + feature tag, + # pre-computed once per distinct geometry shape (typically per + # ``railing_type``-like enum). The draw handler transforms the cached + # local coords with the current frame's billboard + view rotation + # rather than re-running the bmesh build pipeline; this is the + # standard Blender practice of keeping allocations out of draw + # callbacks. The cache is lazily initialised per subclass via + # ``_get_schematic_geometry_cache`` so concurrent consumers don't + # share entries. + _schematic_geometry_cache: dict | None = None + + # Maps a dimension's ``attr_name`` (e.g. "railing_diameter") to a + # feature tag carried on the schematic mesh's edges (e.g. "rail_tube"). + # When the user hovers a dimension whose ``attr_name`` is in this map, + # all edges tagged with the corresponding feature are drawn in + # ``SCHEMATIC_HIGHLIGHT_COLOR`` so the geometric part being measured + # is visually called out. Subclasses opt in by populating this dict; + # the default empty dict gives no highlight (graceful no-op). + schematic_attr_to_feature: dict[str, str] = {} + + # Per-concrete-subclass cache of the feature tag currently hovered. + # Written by ``_update_hovered_feature`` (instance-side, runs in + # ``draw_prepare``) and read by the class-level draw handler. ``None`` + # means "no dimension hovered" (default-coloured pass only). + _hovered_feature: str | None = None + + # Name of the bmesh edge string layer used to tag edges with a feature + # name. Builders write ``edge[layer] = b"rail_tube"``; the cache reads + # the same layer back on extraction. The string layer is preferred + # over an int layer + lookup table because each builder declares its + # tags in plain Python and the extraction path is symmetric. + SCHEMATIC_FEATURE_LAYER_NAME: str = "schematic_feature" + + # ── Abstract hooks ──────────────────────────────────────────────────── + + @classmethod + def build_schematic_mesh(cls, props) -> "bmesh.types.BMesh": + """Return a transient bmesh of the mini preview in schematic-local coordinates. + + Subclasses MUST implement. The returned bmesh's edges are extracted + into a cached list of local-coord ``(Vector, Vector)`` pairs by + ``_get_schematic_local_edges`` and the bmesh is freed immediately + afterward. The draw handler then transforms the cached pairs per + frame — so the bmesh is built once per distinct + ``schematic_cache_key`` value, not once per draw call. + """ + raise NotImplementedError(f"{cls.__name__} must implement build_schematic_mesh(props) -> bmesh.BMesh") + + @classmethod + def schematic_cache_key(cls, props): + """Hashable key identifying the schematic's geometry shape, or ``None`` to disable caching. + + Subclasses whose schematic depends only on a small set of discrete + (e.g. enum-like) props should return a tuple of those — the bmesh + then rebuilds only when the key changes. Returning ``None`` rebuilds + on every draw, appropriate for schematics whose proportions vary + continuously with the bound properties. + + The cached form lives in ``_schematic_geometry_cache`` and is + camera-independent: only schematic-local edge endpoints are stored, + so the cache survives camera moves and only invalidates on key + change. + """ + return None + + # ── Optional hooks ──────────────────────────────────────────────────── + + def schematic_should_show(self, props) -> bool: + """Whether the schematic preview and sliders should be visible this frame. + + Default: tied to ``props.is_editing``. Subclasses can override to + add additional gating (e.g. hide when a sibling edit mode is open). + """ + return bool(getattr(props, "is_editing", False)) + + # ── Lifecycle (overrides ``BaseParametricGizmoGroup``) ──────────────── + + def setup(self, context: bpy.types.Context) -> None: + self.setup_editing_gizmos(context) + self.setup_schematic_dimensions(context) + self.setup_element_specific_gizmos(context) + + def refresh(self, context: bpy.types.Context) -> None: + if not self.is_setup_complete(): + return + obj = context.active_object + if not obj: + return + props = self.get_props(obj) + mw = obj.matrix_world + self._prime_frame_caches(context, mw) + self.update_editing_gizmos(context, mw, props) + self.update_schematic_dimensions(context, mw, props) + self._reconcile_draw_handler(props) + self._refresh_element_specific(context, mw, props) + self._update_hovered_feature() + + def draw_prepare(self, context: bpy.types.Context) -> None: + if not self.is_setup_complete(): + return + if apply_transform_modal_draw_gate(self, context): + return + obj = context.active_object + if not obj: + return + props = self.get_props(obj) + mw = obj.matrix_world + self._prime_frame_caches(context, mw) + self.update_editing_gizmos(context, mw, props) + self.update_schematic_dimensions(context, mw, props) + self._reconcile_draw_handler(props) + self._refresh_element_specific(context, mw, props) + self._update_hovered_feature() + + def _update_hovered_feature(self) -> None: + """Record which feature tag the user is currently hovering on. + + Walks the group's gizmos for the first ``is_highlight=True`` + dimension whose ``schematic_attr_name`` maps into + ``schematic_attr_to_feature``, and writes the corresponding tag + onto the concrete class (so the class-level draw handler can + pick it up). ``None`` is written when nothing eligible is + hovered. Cheap walk — runs once per frame, no allocations. + """ + cls = type(self) + attr_to_feature = cls.schematic_attr_to_feature + if not attr_to_feature: + cls._hovered_feature = None + return + for gz in self.gizmos: + if not getattr(gz, "is_highlight", False): + continue + attr_name = getattr(gz, "schematic_attr_name", None) + if attr_name is None: + continue + feature = attr_to_feature.get(attr_name) + if feature is not None: + cls._hovered_feature = feature + return + cls._hovered_feature = None + + # ── Dimension wiring (schematic-anchored ``BIM_GT_gizmo_dimension`` lines) ── + + # Fixed visual length (as a fraction of ``schematic_box_size``) for every + # Schematic dimension bars render as constant-width labelled tags; the + # value reads from the text label, not bar length. Decouples readability + # from value magnitude — a 5mm thickness and a 5m height are equally + # clickable. Drag distance still maps 1:1 to the property's world units. + SCHEMATIC_DIM_VISIBLE_LENGTH_RATIO: float = 0.6 + + def setup_schematic_dimensions(self, context: bpy.types.Context) -> None: + """Create one ``BIM_GT_gizmo_dimension`` per ``DimensionGizmoConfig``.""" + prefs = tool.Blender.get_addon_preferences() + highlight_color = prefs.decorator_color_selected[:3] + + for config in self.schematic_dimension_props: + gizmo = self.gizmos.new("BIM_GT_gizmo_dimension") + gizmo.move_get_cb = self._make_dimension_getter(config) + gizmo.move_set_cb = self._make_dimension_setter(config) + # Non-zero initial axis; per-frame refresh overwrites with the + # billboarded direction. + gizmo.axis = Vector(config.axis) + # No ``local_axis``: schematic drags must follow the billboarded + # bar (screen-up for a vertical bar), not the object-local axis. + gizmo.invert_delta = config.invert_delta + gizmo.delta_scale = config.delta_scale + gizmo.prop_name = config.prop_name + gizmo.gizmo_group = self + gizmo.text_formatter = config.text_formatter + gizmo.color = self.get_color_from_name(config.color) + gizmo.color_highlight = highlight_color + gizmo.alpha = 1.0 + gizmo.use_draw_modal = True + gizmo.use_draw_scale = False + gizmo.text_offset_sign = config.text_offset_sign + gizmo.text_alignment = config.text_alignment + gizmo.show_start_arrow = config.show_start_arrow + gizmo.show_end_arrow = config.show_end_arrow + gizmo.schematic_attr_name = config.attr_name + setattr(self, f"schematic_dim_{config.attr_name}_gizmo", gizmo) + + def update_schematic_dimensions(self, context: bpy.types.Context, mw: Matrix, props) -> None: + """Position and size each schematic-anchored dimension gizmo.""" + billboard_rot = self._frame_billboard_rot + view_rotation = self.schematic_view_rotation + anchor = self._compute_schematic_anchor(props, mw, billboard_rot) + should_show = self.schematic_should_show(props) + default_length = self.schematic_box_size * self.SCHEMATIC_DIM_VISIBLE_LENGTH_RATIO + + for config in self.schematic_dimension_props: + gizmo = getattr(self, f"schematic_dim_{config.attr_name}_gizmo", None) + if gizmo is None: + continue + + if not should_show: + gizmo.hide = True + continue + if config.visibility_condition is not None and not config.visibility_condition(props): + gizmo.hide = True + continue + if self.is_gizmo_hidden_by_modal(gizmo): + gizmo.hide = True + continue + gizmo.hide = False + + # Freeze geometry transforms while a modal is active so an + # orbit-during-drag can't shift the drag direction under the + # user's hand. + if getattr(gizmo, "is_modal", False): + continue + + local_offset = Vector() + if config.matrix_position is not None: + local_offset = Vector(config.matrix_position(props)) + gizmo.matrix_basis = self._schematic_world_matrix( + anchor, billboard_rot, config.axis, local_offset, view_rotation + ) + + # Drag axis = visual bar direction; keep aligned with the on-screen + # bar even when it points partly into screen depth. + gizmo.axis = (billboard_rot @ view_rotation @ Vector(config.axis)).normalized() + + visible_length = ( + config.schematic_visible_length if config.schematic_visible_length is not None else default_length + ) + gizmo.set_dimension_length(visible_length) + gizmo.show_start_arrow = config.show_start_arrow + gizmo.show_end_arrow = config.show_end_arrow + + # ── Schematic anchor + draw handler lifecycle ───────────────────────── + + def _compute_schematic_anchor(self, props, mw: Matrix, billboard_rot: Matrix) -> Vector: + """World-space anchor of the schematic decoration box (instance entry point).""" + return self.compute_schematic_anchor( + mw, + self.get_element_height(props), + self.ICON_VALIDATE_X, + self.ICON_Z_OFFSET, + billboard_rot, + self.schematic_anchor_offset, + ) + + @staticmethod + def compute_schematic_anchor( + mw: Matrix, + element_height: float, + icon_x: float, + icon_z_offset: float, + billboard_rot: Matrix, + schematic_offset: Vector, + ) -> Vector: + """Schematic-anchor world position: icon-row origin + the schematic + offset rotated into the screen frame. + + The anchor itself stays billboard-aligned regardless of + ``schematic_view_rotation``; tilts are applied to the contents + downstream so the anchored frame stays stable on screen.""" + icon_world = mw @ Vector((icon_x, 0.0, element_height + icon_z_offset)) + return icon_world + billboard_rot @ Vector(schematic_offset) + + @staticmethod + def _schematic_world_matrix( + anchor: Vector, + billboard_rot: Matrix, + axis: tuple[float, float, float], + local_position: tuple[float, float, float] | Vector, + view_rotation: Matrix | None = None, + ) -> Matrix: + """``matrix_basis`` for a schematic-anchored gizmo. + + Translates to ``anchor + billboard_rot @ view_rotation @ local_position`` + and rotates +X to the schematic-local ``axis``.""" + if view_rotation is None: + view_rotation = Matrix.Identity(4) + local_offset = view_rotation @ Vector(local_position) + world_pos = anchor + billboard_rot @ local_offset + axis_world = (billboard_rot @ view_rotation @ Vector(axis)).normalized() + x_to_axis = Vector((1, 0, 0)).rotation_difference(axis_world).to_matrix().to_4x4() + return Matrix.Translation(world_pos) @ x_to_axis + + def _reconcile_draw_handler(self, props) -> None: + """Install or remove the GPU draw handler to match ``schematic_should_show``.""" + if self.schematic_should_show(props): + self._install_draw_handler() + else: + self._uninstall_draw_handler() + + @classmethod + def _get_schematic_geometry_cache(cls) -> dict: + """Return the per-concrete-subclass schematic-geometry cache, creating it on first access. + + Subclass attribute writes via ``cls._schematic_geometry_cache = ...`` + land on the concrete class (not on this base), so two consumer + subclasses keep independent caches. The lazy ``__dict__`` check + ensures each subclass starts with its own empty dict rather than + inheriting (and mutating) the base's. + """ + if "_schematic_geometry_cache" not in cls.__dict__ or cls._schematic_geometry_cache is None: + cls._schematic_geometry_cache = {} + return cls._schematic_geometry_cache + + @classmethod + def _get_schematic_local_edges(cls, props) -> "list[tuple[Vector, Vector, str | None]]": + """Return the schematic's edges as schematic-local ``(v0, v1, tag)`` triples. + + ``tag`` is the feature tag stored on the bmesh edge string layer + named by ``SCHEMATIC_FEATURE_LAYER_NAME`` (empty bytes → ``None``). + Builders that don't tag any edges produce all-``None`` tags; the + draw handler then takes the default-only path. + + Hits the per-subclass cache when ``schematic_cache_key(props)`` is + not ``None`` — the bmesh is built only on cache miss. The cached + list contains only local coordinates + tag strings, so it stays + valid across camera moves; the draw handler applies per-frame + transforms (anchor, billboard rotation, view rotation) at render + time. + + Keeping the bmesh allocation off the draw path is the standard + Blender practice — see the ``ProfileDecorator`` pattern, which + likewise caches its shader and rebuilds geometry only on + state-change rather than per draw call. + """ + key = cls.schematic_cache_key(props) + cache = cls._get_schematic_geometry_cache() + if key is not None and key in cache: + return cache[key] + bm = cls.build_schematic_mesh(props) + try: + feat_layer = bm.edges.layers.string.get(cls.SCHEMATIC_FEATURE_LAYER_NAME) + edges: list[tuple[Vector, Vector, str | None]] = [] + for e in bm.edges: + v0 = Vector(e.verts[0].co) + v1 = Vector(e.verts[1].co) + if feat_layer is None: + tag: str | None = None + else: + raw = e[feat_layer] + tag = raw.decode("utf-8") if raw else None + edges.append((v0, v1, tag)) + finally: + bm.free() + if key is not None: + cache[key] = edges + return edges + + @classmethod + def _install_draw_handler(cls) -> None: + """Register a class-level ``POST_VIEW`` handler on ``SpaceView3D``. + + Idempotent. The class attribute write lands on the concrete subclass + (not on this base), so two schematic consumers (railing, roof, …) + keep independent handles. + """ + if cls._draw_handler_installed is not None: + return + cls._draw_handler_installed = bpy.types.SpaceView3D.draw_handler_add( + cls._schematic_draw_callback, (cls,), "WINDOW", "POST_VIEW" + ) + + @classmethod + def _uninstall_draw_handler(cls) -> None: + """Remove the schematic draw handler if installed. Idempotent.""" + if cls._draw_handler_installed is None: + return + bpy.types.SpaceView3D.draw_handler_remove(cls._draw_handler_installed, "WINDOW") + cls._draw_handler_installed = None + + @classmethod + def _props_for_active(cls): + """``(obj, props)`` for the active+selected object, or ``(None, None)``.""" + obj = tool.Blender.get_active_object(is_selected=True) + if obj is None or not cls.props_getter: + return None, None + props = cls.props_getter(obj) + return obj, props + + @classmethod + def _schematic_draw_callback(cls, owner_cls) -> None: + """GPU callback that renders the schematic mesh as wireframe. + + Self-uninstalls when the active object has no editable schematic props. + Per-frame: rebuilds the bmesh from props, transforms verts into the + schematic frame, batches as line segments via ``POLYLINE_UNIFORM_COLOR``. + """ + obj, props = owner_cls._props_for_active() + if obj is None or props is None or not owner_cls.schematic_should_show_class(props): + owner_cls._uninstall_draw_handler() + return + + context = bpy.context + region = getattr(context, "region", None) + rv3d = getattr(context, "region_data", None) + if region is None or rv3d is None: + return + + try: + local_edges = owner_cls._get_schematic_local_edges(props) + except Exception: + # A subclass build that raises would otherwise crash the viewport + # on every redraw. Drop the handler so the user sees a missing + # schematic instead of a broken Blender; the next refresh will + # try again if conditions allow. + owner_cls._uninstall_draw_handler() + return + + if not local_edges: + return + + mw = obj.matrix_world + billboard_rot = get_billboard_rotation(context) + anchor = owner_cls.compute_schematic_anchor( + mw, + owner_cls._get_element_height_class(props), + owner_cls.ICON_VALIDATE_X, + owner_cls.ICON_Z_OFFSET, + billboard_rot, + owner_cls.schematic_anchor_offset, + ) + + view_rotation = owner_cls.schematic_view_rotation + hovered = getattr(owner_cls, "_hovered_feature", None) + default_segments: list[tuple[float, float, float]] = [] + highlight_segments: list[tuple[float, float, float]] = [] + for v0_local, v1_local, tag in local_edges: + a = tuple(anchor + billboard_rot @ view_rotation @ v0_local) + b = tuple(anchor + billboard_rot @ view_rotation @ v1_local) + if hovered is not None and tag == hovered: + highlight_segments.append(a) + highlight_segments.append(b) + else: + default_segments.append(a) + default_segments.append(b) + + shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR") + shader.bind() + shader.uniform_float("lineWidth", owner_cls.SCHEMATIC_LINE_WIDTH) + shader.uniform_float("viewportSize", (region.width, region.height)) + if default_segments: + shader.uniform_float("color", owner_cls.SCHEMATIC_LINE_COLOR) + batch_for_shader(shader, "LINES", {"pos": default_segments}).draw(shader) + if highlight_segments: + shader.uniform_float("color", owner_cls.SCHEMATIC_HIGHLIGHT_COLOR) + batch_for_shader(shader, "LINES", {"pos": highlight_segments}).draw(shader) + + @classmethod + def schematic_should_show_class(cls, props) -> bool: + """Class-level visibility gate. Mirror any override of the instance form.""" + return bool(getattr(props, "is_editing", False)) + + @classmethod + def _get_element_height_class(cls, props) -> float: + return getattr(props, "overall_height", getattr(props, "height", 1.0)) + + # ── Visual constants ───────────────────────────────────────────────── + + SCHEMATIC_LINE_COLOR: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 0.85) + # Warm amber, opaque, distinguishable against the default white line + # colour and against most Blender themes. Used to overdraw the subset + # of edges tagged with the hovered dimension's feature. + SCHEMATIC_HIGHLIGHT_COLOR: tuple[float, float, float, float] = (1.0, 0.7, 0.2, 0.95) + SCHEMATIC_LINE_WIDTH: float = 1.5 + + +class BaseIconActionGroup(BillboardingGizmoGroupMixin): + """Base for gizmo groups that emit clickable icon-action gizmos. + + Action gizmos invoke an operator on click and have no associated state — + copy Z rotation, snap to host, align to grid, etc. Each subclass declares + ``action_configs: list[IconActionConfig]`` and one icon is emitted per + config, stacked horizontally and billboarded above the active object's + bounding box. + + Override ``is_eligible_object`` to gate when the group polls in. The + default eligibility is "active object is an IFC element"; subclasses + typically also require a selection cardinality. + + The pen / validate / cancel icon row from ``BaseParametricGizmoGroup`` + polls when **exactly one** object is selected, so action gizmos that + require ``len >= 2`` are mutually exclusive with parametric editing — + there is no icon-row overlap in practice. + """ + + action_configs: ClassVar[list[IconActionConfig]] = [] + + # Layout constants. Icons appear above the active object's bounding box, + # billboarded toward the camera. Tweak per-subclass if a feature needs a + # different anchor. ICON_SCALE matches the validate/cancel cycle scale + # used by BaseParametricGizmoGroup at ICON_VALIDATE_X (0.375 ≈ 75% of + # the default gizmo size) so the action icons sit at the same visual + # weight as the parametric-edit icon row. + ICON_ROW_Z_OFFSET = 0.5 + ICON_SPACING_X = 0.4 + ICON_SCALE = 0.375 + + @classmethod + def is_eligible_object(cls, obj: bpy.types.Object) -> bool: + """Subclass override. Default: any IFC element. + + Subclasses commonly add selection-count or IFC-class filters.""" + return tool.Ifc.get_entity(obj) is not None + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + obj = tool.Blender.get_active_object(is_selected=True) + if obj is None: + return False + if not tool.Blender.are_viewport_gizmos_enabled(): + return False + if _is_transform_modal_active(context): + return False + return cls.is_eligible_object(obj) + + def setup(self, context: bpy.types.Context) -> None: + prefs = tool.Blender.get_addon_preferences() + default_color = tuple(prefs.decorations_colour[:3]) + highlight_color = tuple(prefs.decorator_color_selected[:3]) + for config in self.action_configs: + gizmo = self.setup_icon_gizmo(config.icon, default_color, highlight_color, config.operator) + setattr(self, f"action_{config.name}_gizmo", gizmo) + + def get_icon_anchor(self, context: bpy.types.Context) -> Vector | None: + obj = context.active_object + if obj is None: + return None + z_top = max((c[2] for c in obj.bound_box), default=0.0) + return obj.matrix_world @ Vector((0.0, 0.0, z_top + self.ICON_ROW_Z_OFFSET)) + + def position_gizmos(self, context: bpy.types.Context) -> None: + obj = context.active_object + if obj is None: + return + anchor = self.get_icon_anchor(context) + if anchor is None: + return + billboard_rot = get_billboard_rotation(context) + # World-X spacing keeps a billboarded icon row coherent regardless + # of anchor object rotation. + for i, config in enumerate(self.action_configs): + gizmo = getattr(self, f"action_{config.name}_gizmo", None) + if gizmo is None: + continue + if config.visibility_condition is not None and not config.visibility_condition(obj): + gizmo.hide = True + continue + gizmo.hide = False + pos = anchor + Vector((i * self.ICON_SPACING_X, 0.0, 0.0)) + gizmo.matrix_basis = billboarded_at(pos, billboard_rot, scale=self.ICON_SCALE) diff --git a/src/bonsai/bonsai/bim/module/drawing/helper.py b/src/bonsai/bonsai/bim/module/drawing/helper.py index d4895410cb..ef72207914 100644 --- a/src/bonsai/bonsai/bim/module/drawing/helper.py +++ b/src/bonsai/bonsai/bim/module/drawing/helper.py @@ -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: diff --git a/src/bonsai/bonsai/bim/module/geometry/data.py b/src/bonsai/bonsai/bim/module/geometry/data.py index 05344ab87e..f88eb967ec 100644 --- a/src/bonsai/bonsai/bim/module/geometry/data.py +++ b/src/bonsai/bonsai/bim/module/geometry/data.py @@ -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) diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index d210fbfa57..47c6b75dfa 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -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: @@ -545,6 +546,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 @@ -1023,10 +1031,10 @@ class OverrideDelete(bpy.types.Operator): 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) @@ -1180,7 +1188,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"} @@ -1284,6 +1292,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") @@ -2220,6 +2231,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 @@ -2261,6 +2274,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"} @@ -2347,6 +2362,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) @@ -2355,6 +2371,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) @@ -2482,9 +2499,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() @@ -3153,7 +3170,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 diff --git a/src/bonsai/bonsai/bim/module/geometry/ui.py b/src/bonsai/bonsai/bim/module/geometry/ui.py index e7912c7a77..212e6ca74b 100644 --- a/src/bonsai/bonsai/bim/module/geometry/ui.py +++ b/src/bonsai/bonsai/bim/module/geometry/ui.py @@ -19,6 +19,7 @@ import bpy from bpy.types import Menu, Panel, UIList +import ifcopenshell.util.unit import bonsai.bim import bonsai.tool as tool from bonsai.bim.helper import prop_with_search @@ -483,10 +484,32 @@ class BIM_PT_placement(Panel): row.label(text="No Object Placement Found") return + is_imperial = False + if tool.Ifc.get(): + length_unit = ifcopenshell.util.unit.get_project_unit(tool.Ifc.get(), "LENGTHUNIT") + if length_unit and length_unit.Name != "METRE": + is_imperial = True + row = self.layout.row() - row.prop(context.active_object, "location", text="Location") + row.label(text="Location:") + + if is_imperial: + loc = context.active_object.location + for i, (axis, comp) in enumerate(zip("XYZ", (loc.x, loc.y, loc.z))): + split = self.layout.split(factor=0.6) + split.prop(context.active_object, "location", index=i, text=axis) + sub = split.row() + sub.enabled = False + sub.alignment = "LEFT" + sub.label(text=tool.Unit.format_distance(comp)) + else: + for i, axis in enumerate("XYZ"): + self.layout.prop(context.active_object, "location", index=i, text=axis) + row = self.layout.row() - row.prop(context.active_object, "rotation_euler", text="Rotation") + row.label(text="Rotation:") + for i, axis in enumerate("XYZ"): + self.layout.prop(context.active_object, "rotation_euler", index=i, text=axis) if props.blender_offset_type != "NONE": row = self.layout.row(align=True) diff --git a/src/bonsai/bonsai/bim/module/material/operator.py b/src/bonsai/bonsai/bim/module/material/operator.py index 7587a7e4ed..6ec1ccf188 100644 --- a/src/bonsai/bonsai/bim/module/material/operator.py +++ b/src/bonsai/bonsai/bim/module/material/operator.py @@ -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) diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index 9fbd631003..b30fb17896 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -15,11 +15,15 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# 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,48 @@ 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.GizmoWallJoinIntersection, + wall.GizmoWallLinkToggle, + wall.GizmoWallUnjoinSingle, + wall.JoinWallsIntersection, wall.MergeWall, wall.OffsetWalls, wall.RecalculateWall, + wall.RotateWall90, wall.SplitWall, + wall.SplitWallAtCursor, + wall.UnjoinWallPathConnection, wall.UnjoinWalls, + wall.EnableWallFilletPreview, + wall.FinishWallFilletPreview, + wall.CancelWallFilletPreview, + wall.EnableWallFilletPreviewFromCorner, + wall.CreateWallFillet, opening.AddBoolean, opening.CloneOpening, opening.EditOpenings, @@ -94,6 +138,7 @@ classes = ( opening.RemoveBoolean, opening.SelectBoolean, opening.ShowOpenings, + opening.ToggleHostOpenings, opening.UpdateOpeningsFocus, profile.ChangeCardinalPoint, profile.ChangeProfileDepth, @@ -140,10 +185,18 @@ classes = ( prop.BIMDoorProperties, prop.BIMRailingProperties, prop.BIMRoofProperties, + 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 +217,8 @@ classes = ( stair.ToggleStairProperty, stair.AdjustStairTreads, stair.SetStairTreads, - stair.CycleStairType, + stair.InputStairTreads, + stair.PickStairType, stair.GizmoStairEdition, sverchok_modifier.CreateNewSverchokGraph, sverchok_modifier.UpdateDataFromSverchok, @@ -177,7 +231,7 @@ classes = ( window.FinishEditingWindow, window.EnableEditingWindow, window.RemoveWindow, - window.CycleWindowType, + window.PickWindowType, window.GizmoWindowEdition, door.BIM_OT_add_door, door.AddDoor, @@ -186,7 +240,7 @@ classes = ( door.EnableEditingDoor, door.RemoveDoor, door.ToggleDoorSwing, - door.CycleDoorType, + door.PickDoorType, door.GizmoDoorEdition, railing.BIM_OT_add_railing, railing.CopyRailingParameters, @@ -203,16 +257,41 @@ 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.MEPUnjoinAtPort, + mep.MEPRemoveTerminalFitting, + mep.MEPUnjoinPair, + 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 +343,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 ``BIMProperties`` 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 +362,12 @@ def register(): def unregister(): + # DecorationsHandler is installed lazily by bim.show_openings; tear it down + # (along with its persistent depsgraph / undo / redo / load cache handlers) + # before the rest of unregister so those handlers can't fire against + # half-unloaded module state. + opening.DecorationsHandler.uninstall() + if not bpy.app.background: for tool_data in reversed(tools): bpy.utils.unregister_tool(tool_data.tool) @@ -288,13 +375,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) diff --git a/src/bonsai/bonsai/bim/module/model/array.py b/src/bonsai/bonsai/bim/module/model/array.py index dd54bf0ab3..bef8f6fe11 100644 --- a/src/bonsai/bonsai/bim/module/model/array.py +++ b/src/bonsai/bonsai/bim/module/model/array.py @@ -15,24 +15,119 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# This file was modified with the assistance of an AI coding tool. import json +from typing import ClassVar import bpy +import ifcopenshell import ifcopenshell.api.pset import ifcopenshell.util.element import ifcopenshell.util.unit -from mathutils import Matrix +from mathutils import Matrix, Vector +import bonsai.bim.module.drawing.gizmos as gizmo import bonsai.tool as tool +from bonsai.bim.decorator_cache import TokenCache +from bonsai.bim.module.drawing.gizmos import ( + COLOR_GREEN, + COLOR_RED, + DimensionGizmoConfig, + IconSlot, +) +from bonsai.bim.module.model.decorator import ( + _BBOX_EDGES, + _BBOX_HIGHLIGHT_LINE_ALPHA, + _BBOX_HIGHLIGHT_LINE_WIDTH, + bbox_world_edges, + draw_polyline_segments, +) +from bonsai.bim.parametric_lifecycle import ( + IntegerInputDialogMixin, + ParametricEditMixinBase, +) + + +def _wipe_array_children(layers: list) -> None: + """Delete every existing array child and clear ``children`` GUID lists. + + Rebuilding mints fresh GlobalIds, so external references (BCF, IDS, etc.) + to the old GUIDs go stale. The bbox heuristic skips the wipe when the + parent's geometry is unchanged.""" + for layer in layers: + for child_guid in layer.get("children", []): + try: + child_element = tool.Ifc.get().by_guid(child_guid) + except RuntimeError: + continue + child_obj = tool.Ifc.get_object(child_element) + if child_obj is not None: + tool.Geometry.delete_ifc_object(child_obj) + layer["children"] = [] + + +_BBOX_EQUALITY_EPS = 1e-5 + + +def _resolve_array_edit_props(context: bpy.types.Context): + """Resolve the active object's array props during an active edit + lifecycle. Returns ``None`` when there's no active object or the user + isn't mid-edit. Used as the execute / poll prologue for operators + bound to the array edit gizmos so they no-op cleanly outside the + edit lifecycle without bypassing the commit lifecycle.""" + obj = context.active_object + if obj is None: + return None + props = tool.Model.get_array_props(obj) + if not props.is_editing: + return None + return props + + +def _array_children_need_rebuild(parent_obj, layers: list) -> bool: + """Cheap drift detector: True when the parent's local bbox dimensions + differ from the first resolvable child's, indicating a parent geometry + edit since the last array regen. Misses edits that preserve bbox + dimensions (e.g. shape changes within the same envelope); those need + a manual "Regenerate Array" via the UI button. + + Runs at array-edit finish as a safety net: when True, callers wipe and + rebuild children so the array picks up the drift; when False, in-place + transform updates suffice.""" + if not parent_obj.bound_box: + return True + parent_dims = tool.Blender.get_object_bounding_box(parent_obj)["dimensions"] + for layer in layers: + for child_guid in layer.get("children", []): + try: + child_element = tool.Ifc.get().by_guid(child_guid) + except RuntimeError: + continue + child_obj = tool.Ifc.get_object(child_element) + if child_obj is None or not child_obj.bound_box: + continue + child_dims = tool.Blender.get_object_bounding_box(child_obj)["dimensions"] + return any(abs(a - b) > _BBOX_EQUALITY_EPS for a, b in zip(parent_dims, child_dims)) + return False class AddArray(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_array" bl_label = "Add Array" - bl_description = "Add Bonsai parametric array to the active IFC element" + bl_description = "Add an array of the active object" bl_options = {"REGISTER", "UNDO"} + # Optional parameters — defaults preserve the existing UX (count=1, no offset) + # for the panel + script callers. The gizmo-driven path (see + # ``AddArrayFromFeatureEdit``) passes bbox-derived values so the new array + # has a visible second instance and interactable offset gizmos out of the box. + count: bpy.props.IntProperty(name="Count", default=1, min=1) + x: bpy.props.FloatProperty(name="X Offset", default=0.0) + y: bpy.props.FloatProperty(name="Y Offset", default=0.0) + z: bpy.props.FloatProperty(name="Z Offset", default=0.0) + def _execute(self, context): assert (obj := context.active_object) assert (element := tool.Ifc.get_entity(obj)) @@ -54,12 +149,13 @@ class AddArray(bpy.types.Operator, tool.Ifc.Operator): array = { "children": [], - "count": 1, - "x": 0.0, - "y": 0.0, - "z": 0.0, + "count": self.count, + "x": self.x, + "y": self.y, + "z": self.z, "use_local_space": True, "method": "OFFSET", + "per_child_opening": True, } pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") @@ -78,98 +174,217 @@ class AddArray(bpy.types.Operator, tool.Ifc.Operator): properties={"Parent": element.GlobalId, "Data": ifc_file.create_entity("IfcText", json.dumps(data))}, ) + # Always regenerate so callers passing count >= 2 see the second+ instance + # appear immediately. No-op for count=1 layers. + tool.Model.regenerate_array(obj, data) + tool.Array.constrain_children_to_parent(element) -class DisableEditingArray(bpy.types.Operator): - bl_idname = "bim.disable_editing_array" - bl_label = "Disable Editing Array" - bl_options = {"REGISTER", "UNDO"} - def execute(self, context): +class _ArrayEditMixin(ParametricEditMixinBase): + """Array edit lifecycle scoped to one layer at a time. + + ``is_editing`` is paired with ``editing_item_index`` so Finish/Cancel + know which layer to commit/discard. Gizmo drag mutates props in place; + IFC writes happen only at Finish.""" + + pset_name = "BBIM_Array" + + @classmethod + def _is_element_type(cls, element): + return tool.Parametric.is_array(element) + + @classmethod + def _get_props(cls, obj: bpy.types.Object): + return tool.Model.get_array_props(obj) + + @classmethod + def _iter_targets(cls, context: bpy.types.Context) -> list[bpy.types.Object]: obj = context.active_object - assert obj - tool.Model.get_array_props(obj).is_editing = -1 - return {"FINISHED"} + return [obj] if obj else [] - -class EnableEditingArray(bpy.types.Operator): - bl_idname = "bim.enable_editing_array" - bl_label = "Enable Editing Array" - bl_options = {"REGISTER", "UNDO"} - item: bpy.props.IntProperty() - - def execute(self, context): - obj = context.active_object - assert obj + @classmethod + def _resolve(cls, obj: bpy.types.Object): element = tool.Ifc.get_entity(obj) - props = tool.Model.get_array_props(obj) + if not element or not cls._is_element_type(element): + return None + return element, cls._get_props(obj) - relating_obj = props.relating_array_object + @classmethod + def _read_layers(cls, element) -> list: + return json.loads(ifcopenshell.util.element.get_pset(element, cls.pset_name, "Data") or "[]") - if relating_obj: - element = tool.Ifc.get_entity(relating_obj) - parent_globalid = ifcopenshell.util.element.get_pset(element, "BBIM_Array", "Parent") - parent_element = tool.Ifc.get().by_guid(parent_globalid) - data = json.loads(ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array", "Data"))[self.item] - else: - data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Array", "Data"))[self.item] - props.count = data["count"] + @classmethod + def _hydrate_props_from_layer(cls, props, layer: dict, si_conversion: float) -> None: + props.count = layer["count"] + props.x = layer["x"] * si_conversion + props.y = layer["y"] * si_conversion + props.z = layer["z"] * si_conversion + props.use_local_space = layer.get("use_local_space", True) + props.method = layer.get("method", "OFFSET") + props.per_child_opening = layer.get("per_child_opening", layer.get("mirror_to_host", True)) + + @classmethod + def _set_children_visibility(cls, element, hidden: bool) -> None: + """Hide array children during edit so only the preview ghosts show. + Auto-commit unhides on save; load-time heal clears stale flags.""" + layers = cls._read_layers(element) + for layer in layers: + for child_guid in layer.get("children", []): + try: + child_element = tool.Ifc.get().by_guid(child_guid) + except RuntimeError: + continue + child_obj = tool.Ifc.get_object(child_element) + if child_obj is not None: + child_obj.hide_set(hidden) + + @classmethod + def _enable_one(cls, obj: bpy.types.Object, item: int = 0) -> None: + """Enter array editing for layer ``item``; no-op for out-of-range index. + + If ``props.relating_array_object`` points at another array parent, + seed the draft props from that object's matching layer.""" + resolved = cls._resolve(obj) + if resolved is None: + return + element, props = resolved + cls._handle_drift_on_enable(obj) + source_layers = cls._layers_from_relating(props) or cls._read_layers(element) + if item < 0 or item >= len(source_layers): + return si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - props.x = data["x"] * si_conversion - props.y = data["y"] * si_conversion - props.z = data["z"] * si_conversion - props.use_local_space = data.get("use_local_space", False) - props.method = data.get("method", "OFFSET") - - props.is_editing = self.item - return {"FINISHED"} - - -class EditArray(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.edit_array" - bl_label = "Edit Array" - bl_options = {"REGISTER", "UNDO"} - item: bpy.props.IntProperty() - - def _execute(self, context): - obj = context.active_object - element = tool.Ifc.get_entity(obj) - props = tool.Model.get_array_props(obj) - si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - - pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") - data = json.loads(pset["Data"]) - data[self.item] = { - "children": data[self.item]["children"], - "count": props.count, - "x": props.x / si_conversion, - "y": props.y / si_conversion, - "z": props.z / si_conversion, - "use_local_space": props.use_local_space, - "method": props.method, - } - - props.is_editing = -1 + cls._hydrate_props_from_layer(props, source_layers[item], si_conversion) + props.is_editing = True + props.editing_item_index = item + cls._set_children_visibility(element, hidden=True) + @classmethod + def _layers_from_relating(cls, props) -> list | None: + """If ``props.relating_array_object`` is set and resolves to another + array parent, return its layers; else ``None``.""" + relating = getattr(props, "relating_array_object", None) + if relating is None: + return None + element = tool.Ifc.get_entity(relating) + if element is None: + return None + parent_guid = ifcopenshell.util.element.get_pset(element, "BBIM_Array", "Parent") + if not parent_guid: + return None try: - parent_element = tool.Ifc.get().by_guid(pset["Parent"]) - parent = tool.Ifc.get_object(parent_element) - except: - return {"FINISHED"} + parent_element = tool.Ifc.get().by_guid(parent_guid) + except RuntimeError: + return None + data_text = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array", "Data") + if not data_text: + return None + try: + return json.loads(data_text) + except (ValueError, TypeError): + return None - tool.Blender.Modifier.Array.remove_constraints(parent_element) - tool.Model.regenerate_array(parent, data) - tool.Blender.Modifier.Array.set_children_lock_state(element, self.item, True) - tool.Blender.Modifier.Array.constrain_children_to_parent(element) - - # clears the relating_array_object so it doesn't show again next time + @classmethod + def _finish_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: + """Commit the in-progress edit to ``editing_item_index``'s layer. + Drift (layer removed mid-edit) clears the flag and aborts.""" + resolved = cls._resolve(obj) + if resolved is None: + return + element, props = resolved + layers = cls._read_layers(element) + item = props.editing_item_index + if item < 0 or item >= len(layers): + props.is_editing = False + props.editing_item_index = -1 + return + si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + layers[item]["count"] = props.count + layers[item]["x"] = props.x / si_conversion + layers[item]["y"] = props.y / si_conversion + layers[item]["z"] = props.z / si_conversion + layers[item]["use_local_space"] = props.use_local_space + layers[item]["method"] = props.method + layers[item]["per_child_opening"] = props.per_child_opening + # Note: ``tool.Model.regenerate_array`` below removes and re-adds the + # BBIM_Array pset with the in-memory ``layers`` data ([tool/model.py: + # 1163-1167](src/bonsai/bonsai/tool/model.py#L1163-L1167)), so an + # explicit ``edit_pset`` call here would just be overwritten — and + # each redundant call adds an entry to the IFC owner-history audit + # trail. Rely on the regenerator's pset write instead. + tool.Array.remove_constraints(element) + # Wipe-and-rebuild only when the parent's bbox dims differ from the + # children's. For pure count / offset edits the children are already + # valid and ``regenerate_array``'s in-place transform updates are + # enough — saves the delete + re-duplicate cost per instance on + # large arrays. + if _array_children_need_rebuild(obj, layers): + _wipe_array_children(layers) + tool.Model.regenerate_array(obj, layers) + tool.Array.set_children_lock_state(element, item, True) + tool.Array.constrain_children_to_parent(element) + # Set only on success: if any IFC op above raised, the draft survives for retry. + props.is_editing = False + props.editing_item_index = -1 props.relating_array_object = None + # Unhide the (possibly newly-regenerated) children so the user sees + # the committed result. Mirrors the hide in ``_enable_one``. + cls._set_children_visibility(element, hidden=False) + + @classmethod + def _cancel_one(cls, obj: bpy.types.Object) -> None: + resolved = cls._resolve(obj) + if resolved is None: + return + element, props = resolved + layers = cls._read_layers(element) + item = props.editing_item_index + if 0 <= item < len(layers): + si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + cls._hydrate_props_from_layer(props, layers[item], si_conversion) + props.is_editing = False + props.editing_item_index = -1 + # Restore visibility — the pset is unchanged from when we hid them, so + # the children's committed positions are still where they were before. + cls._set_children_visibility(element, hidden=False) + + def _enable_targets(self, context: bpy.types.Context, item: int = 0) -> set[str]: + for obj in self._iter_targets(context): + self._enable_one(obj, item=item) + return {"FINISHED"} + + def _finish_targets(self, context: bpy.types.Context) -> set[str]: + for obj in self._iter_targets(context): + self._finish_one(obj, context) + return {"FINISHED"} + + def _cancel_targets(self, context: bpy.types.Context) -> set[str]: + for obj in self._iter_targets(context): + self._cancel_one(obj) + return {"FINISHED"} + + +EnableEditingArray, FinishEditingArray, CancelEditingArray = tool.Parametric.build_edit_lifecycle( + "array", + _ArrayEditMixin, + labels=( + ( + "Enable Editing Array", + "Edit this array — drag the offset arrows, adjust the count, switch the spacing method", + ), + ("Finish Editing Array", "Save the array changes and rebuild the copies"), + ("Cancel Editing Array", "Discard the array changes and leave the existing copies as they were"), + ), + enable_extra_props={"item": bpy.props.IntProperty(name="Layer Index", default=0, min=0)}, + enable_extra_kwargs=lambda self: {"item": self.item}, + module_name=__name__, +) class ApplyArray(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.apply_array" bl_label = "Apply Array" bl_options = {"REGISTER", "UNDO"} - bl_description = "Apply the array and keep children as separate entities. Only available for the last array" + bl_description = "Convert the array's copies into independent objects (last layer only)" def _execute(self, context): obj = context.active_object @@ -183,17 +398,26 @@ class ApplyArray(bpy.types.Operator, tool.Ifc.Operator): class RegenerateArray(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.regenerate_array" bl_label = "Regenerate Array" + bl_description = "Rebuild the array's copies from the original (works on parent or any copy)" bl_options = {"REGISTER", "UNDO"} def _execute(self, context): obj = context.active_object element = tool.Ifc.get_entity(obj) pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + if not pset or "Parent" not in pset: + self.report({"ERROR"}, "Active object is not part of a Bonsai parametric array.") + return {"CANCELLED"} try: parent_element = tool.Ifc.get().by_guid(pset["Parent"]) - parent = tool.Ifc.get_object(parent_element) - except: - return {"FINISHED"} + except RuntimeError: + self.report( + {"ERROR"}, + f"Array parent GlobalId {pset['Parent']!r} not found — the array's parent " + "element was deleted externally. Reseat the array by re-creating it.", + ) + return {"CANCELLED"} + parent = tool.Ifc.get_object(parent_element) pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array") arrays = json.loads(pset["Data"]) pset = tool.Ifc.get().by_id(pset["id"]) @@ -202,14 +426,21 @@ class RegenerateArray(bpy.types.Operator, tool.Ifc.Operator): if child_obj := tool.Ifc.get_object(tool.Ifc.get().by_guid(child)): tool.Geometry.delete_ifc_object(child_obj) array["children"].clear() - print("cleared array", arrays) - tool.Model.regenerate_array(obj, arrays) - tool.Blender.Modifier.Array.constrain_children_to_parent(element) + # Always operate on the parent — this operator can be invoked with + # either the parent OR any array child as active_object (the per-child + # gizmo group fires it from a child selection). Using ``obj`` / + # ``element`` directly would feed a child to ``regenerate_array`` and + # constrain children against a sibling, silently corrupting the array. + tool.Model.regenerate_array(parent, arrays) + tool.Array.constrain_children_to_parent(parent_element) class RemoveArray(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.remove_array" bl_label = "Remove Array" + bl_description = ( + "Remove this array layer (enable 'Keep Objects' to keep its copies as independent objects — last layer only)" + ) bl_options = {"REGISTER", "UNDO"} item: bpy.props.IntProperty() keep_objs: bpy.props.BoolProperty(name="Keep Objects", default=False) @@ -228,7 +459,7 @@ class RemoveArray(bpy.types.Operator, tool.Ifc.Operator): ) return {"FINISHED"} - props.is_editing = -1 + props.editing_item_index = -1 try: parent_element = tool.Ifc.get().by_guid(pset["Parent"]) @@ -237,12 +468,12 @@ class RemoveArray(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} if self.keep_objs: - tool.Blender.Modifier.Array.bake_children_transform(element, self.item) - tool.Blender.Modifier.Array.set_children_lock_state(element, self.item, False) + tool.Array.bake_children_transform(element, self.item) + tool.Array.set_children_lock_state(element, self.item, False) if not self.keep_objs: data[self.item]["count"] = 1 - tool.Blender.Modifier.Array.remove_constraints(parent_element) + tool.Array.remove_constraints(parent_element) tool.Model.regenerate_array(parent, data, array_layers_to_apply=[self.item] if self.keep_objs else []) pset = tool.Pset.get_element_pset(element, "BBIM_Array") @@ -252,12 +483,13 @@ class RemoveArray(bpy.types.Operator, tool.Ifc.Operator): del data[self.item] data = tool.Ifc.get().createIfcText(json.dumps(data)) ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": data}) - tool.Blender.Modifier.Array.constrain_children_to_parent(element) + tool.Array.constrain_children_to_parent(element) class SelectArrayParent(bpy.types.Operator): bl_idname = "bim.select_array_parent" bl_label = "Select Array Parent" + bl_description = "Select the original object that this array copy belongs to" bl_options = {"REGISTER", "UNDO"} @classmethod @@ -290,6 +522,7 @@ class SelectArrayParent(bpy.types.Operator): class SelectAllArrayObjects(bpy.types.Operator): bl_idname = "bim.select_all_array_objects" bl_label = "Select All Array Objects" + bl_description = "Select the original object and all of its array copies" bl_options = {"REGISTER", "UNDO"} @classmethod @@ -320,7 +553,7 @@ class SelectAllArrayObjects(bpy.types.Operator): self.report({"ERROR"}, f"Objects that don't have an array parent, were deselected.") object.select_set(False) - array_objects = tool.Blender.Modifier.Array.get_all_objects(parent_element) + array_objects = tool.Array.get_all_objects(parent_element) tool.Blender.set_objects_selection( context, active_object=array_objects[0], @@ -330,9 +563,128 @@ class SelectAllArrayObjects(bpy.types.Operator): return {"FINISHED"} +class ArrayParentGizmoClick(bpy.types.Operator): + """Dispatcher for the parent-tree gizmo on array children. + + - Click: select the array's parent. + - Shift+Click: select parent + all children. + - Ctrl+Click: select all children, excluding the parent.""" + + bl_idname = "bim.array_parent_gizmo_click" + bl_label = "Select Array Parent / Family" + bl_description = ( + "Click: select the original object.\n" + "Shift+Click: select the original object and all of its copies.\n" + "Ctrl+Click: select only the copies" + ) + bl_options = {"REGISTER", "UNDO"} + + mode: bpy.props.EnumProperty( + name="Mode", + items=[ + ("PARENT", "Parent", "Select the array parent only"), + ("ALL", "All", "Select parent + every child"), + ("CHILDREN", "Children", "Select every child, excluding the parent"), + ], + default="PARENT", + ) + + @classmethod + def poll(cls, context): + if not context.active_object: + cls.poll_message_set("No active object selected") + return False + return True + + def invoke(self, context, event): + if event.shift: + self.mode = "ALL" + elif event.ctrl: + self.mode = "CHILDREN" + else: + self.mode = "PARENT" + return self.execute(context) + + def execute(self, context): + if self.mode == "PARENT": + return bpy.ops.bim.select_array_parent("EXEC_DEFAULT") + if self.mode == "ALL": + return bpy.ops.bim.select_all_array_objects("EXEC_DEFAULT") + # CHILDREN: resolve the parent of the active child, then select every + # child of that parent's array without the parent itself. + obj = context.active_object + element = tool.Ifc.get_entity(obj) + if element is None: + self.report({"ERROR"}, "Active object is not IFC-linked.") + return {"CANCELLED"} + array_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + if not array_pset: + self.report({"ERROR"}, "Object is not part of an array.") + return {"CANCELLED"} + try: + parent_element = tool.Ifc.get().by_guid(array_pset["Parent"]) + except RuntimeError: + self.report({"ERROR"}, f"Couldn't find array parent by guid '{array_pset['Parent']}'") + return {"CANCELLED"} + all_objects = tool.Array.get_all_objects(parent_element) + parent_obj = tool.Ifc.get_object(parent_element) + children = [o for o in all_objects if o is not parent_obj] + if not children: + self.report({"INFO"}, "Array has no children to select.") + return {"FINISHED"} + tool.Blender.set_objects_selection( + context, + active_object=children[0], + selected_objects=children, + clear_previous_selection=True, + ) + return {"FINISHED"} + + +class EditArrayFromChild(bpy.types.Operator): + bl_idname = "bim.edit_array_from_child" + bl_label = "Edit Array From Child" + bl_description = "Edit the array this copy belongs to" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + obj = context.active_object + if not obj: + cls.poll_message_set("No active object selected") + return False + return True + + def execute(self, context): + obj = context.active_object + element = tool.Ifc.get_entity(obj) + if element is None: + self.report({"ERROR"}, "Active object is not IFC-linked.") + return {"CANCELLED"} + array_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + if not array_pset: + self.report({"ERROR"}, "Object is not part of an array.") + return {"CANCELLED"} + try: + parent_element = tool.Ifc.get().by_guid(array_pset["Parent"]) + except RuntimeError: + self.report({"ERROR"}, f"Couldn't find array parent by guid '{array_pset['Parent']}'") + return {"CANCELLED"} + parent_obj = tool.Ifc.get_object(parent_element) + if not parent_obj: + self.report({"ERROR"}, "Array parent has no Blender object.") + return {"CANCELLED"} + layer_index = tool.Array.get_child_layer_index(element) + if layer_index is None: + layer_index = 0 + tool.Blender.select_and_activate_single_object(context, active_object=parent_obj) + return bpy.ops.bim.enable_editing_array("INVOKE_DEFAULT", item=layer_index) + + class Input3DCursorXArray(bpy.types.Operator): bl_idname = "bim.input_cursor_x_array" bl_label = "Get 3d Cursor X Input for Array" + bl_description = "Set the X offset from the 3D cursor position" bl_options = {"REGISTER", "UNDO"} def execute(self, context): @@ -350,6 +702,7 @@ class Input3DCursorXArray(bpy.types.Operator): class Input3DCursorYArray(bpy.types.Operator): bl_idname = "bim.input_cursor_y_array" bl_label = "Get 3d Cursor Y Input for Array" + bl_description = "Set the Y offset from the 3D cursor position" bl_options = {"REGISTER", "UNDO"} def execute(self, context): @@ -367,6 +720,7 @@ class Input3DCursorYArray(bpy.types.Operator): class Input3DCursorZArray(bpy.types.Operator): bl_idname = "bim.input_cursor_z_array" bl_label = "Get 3d Cursor Z Input for Array" + bl_description = "Set the Z offset from the 3D cursor position" bl_options = {"REGISTER", "UNDO"} def execute(self, context): @@ -379,3 +733,970 @@ class Input3DCursorZArray(bpy.types.Operator): else: props.z = cursor.location.z - obj.location.z return {"FINISHED"} + + +class AddArrayFromFeatureEdit(bpy.types.Operator, tool.Ifc.Operator): + """Commit any in-progress feature edit and add an array with + gizmo-friendly defaults (count=2, offset = bbox extent along the axis). + + Modifier-aware: plain click → X, Shift → Y, Ctrl → Z. Callers can pass + ``axis="X"`` via EXEC_DEFAULT to bypass the modifier read. + + All three chained operators (feature finish + add_array + enable_editing) + run inside one transaction for a single undo step.""" + + bl_idname = "bim.add_array_from_feature_edit" + bl_label = "Add Array" + bl_description = ( + "Click: add an array along X.\n" "Shift+Click: add an array along Y.\n" "Ctrl+Click: add an array along Z" + ) + bl_options = {"REGISTER", "UNDO"} + + axis: bpy.props.EnumProperty( + name="Offset Axis", + items=[ + ("X", "X", "Offset along the object's X axis (bbox X extent)"), + ("Y", "Y", "Offset along the object's Y axis (bbox Y extent)"), + ("Z", "Z", "Offset along the object's Z axis (bbox Z extent)"), + ], + default="X", + ) + + # Minimum offset to use when the object's bbox extent is tiny — prevents + # the second instance from visually overlapping the parent on small + # annotations / openings (0.3m ≈ a clearly-separated next-instance distance). + MIN_DEFAULT_OFFSET = 0.3 + + def invoke(self, context, event): + # Modifier-aware axis pick: X by default, Shift → Y, Ctrl → Z. + if event.shift: + self.axis = "Y" + elif event.ctrl: + self.axis = "Z" + else: + self.axis = "X" + return self.execute(context) + + def _execute(self, context): + obj = context.active_object + if obj is None: + return {"CANCELLED"} + # Commit any in-progress parametric edit lifecycle on this object first — the + # user expects "Add Array" to also finalise whatever they were editing + # so they don't lose their draft changes. + editing = tool.Parametric.is_object_editing(obj, skip_name="array") + if editing is not None: + finish_op_name = editing.finish_op.removeprefix("bim.") + getattr(bpy.ops.bim, finish_op_name)("INVOKE_DEFAULT") + # Bounding-box derived offset along the chosen axis, converted from + # Blender SI (meters) to IFC project units (which is what + # ``BBIM_Array.Data`` stores; the regenerator multiplies by + # unit_scale on the way out). + axis_idx = "XYZ".index(self.axis) + if obj.bound_box: + bbox_extent_si = max(c[axis_idx] for c in obj.bound_box) - min(c[axis_idx] for c in obj.bound_box) + else: + bbox_extent_si = 1.0 + bbox_extent_si = max(bbox_extent_si, self.MIN_DEFAULT_OFFSET) + si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + offset_project = bbox_extent_si / si_conversion if si_conversion else bbox_extent_si + add_kwargs = {"count": 2, "x": 0.0, "y": 0.0, "z": 0.0} + add_kwargs[self.axis.lower()] = offset_project + result = bpy.ops.bim.add_array(**add_kwargs) + if result != {"FINISHED"}: + return result + # Restore selection to just the parent. ``regenerate_array`` calls + # ``tool.Geometry.duplicate_ifc_objects`` which leaves the newly-created + # child selected alongside the parent. The edit-lifecycle gizmos poll on a + # single-selected parent, so with both selected the gizmos wouldn't + # surface and "ARRAY → enter edit" would feel broken. + tool.Blender.select_and_activate_single_object(context, active_object=obj) + # Chain straight into array edit for the newly-added layer (always the + # last entry in the pset's Data list, by AddArray's append semantics). + # The user's expectation after clicking ARRAY is "I want to tweak this + # array now" — entering edit mode immediately collapses the 2-click + # discover-then-edit flow into one. + element = tool.Ifc.get_entity(obj) + if element is None: + return {"FINISHED"} + data_text = ifcopenshell.util.element.get_pset(element, "BBIM_Array", "Data") + if not data_text: + return {"FINISHED"} + try: + layers = json.loads(data_text) + except (ValueError, TypeError): + return {"FINISHED"} + if not layers: + return {"FINISHED"} + bpy.ops.bim.enable_editing_array("INVOKE_DEFAULT", item=len(layers) - 1) + return {"FINISHED"} + + +class ArrayGizmoClick(bpy.types.Operator): + """Per-layer ARRAY gizmo dispatcher: click enters edit for that layer, + Shift+click adds a new layer with bbox-derived defaults. + + Wired to each layer icon in ``GizmoArrayEdition``'s row-of-arrays. The + ``item`` property identifies which layer the user clicked, so the same + operator class is reused across all surfaced layer gizmos.""" + + bl_idname = "bim.array_gizmo_click" + bl_label = "Array Layer" + bl_description = "Click: edit this array layer.\n" "Shift+Click: add another array layer" + bl_options = {"REGISTER", "UNDO"} + + item: bpy.props.IntProperty(name="Layer Index", default=0, min=0) + + def invoke(self, context, event): + if event.shift: + # Pass ``axis="X"`` via EXEC_DEFAULT to bypass + # ``AddArrayFromFeatureEdit.invoke``'s modifier read — Shift on + # the layer-indicator gizmo already means "add new layer", so + # we shouldn't reinterpret it as "axis = Y" downstream. + return bpy.ops.bim.add_array_from_feature_edit("EXEC_DEFAULT", axis="X") + return bpy.ops.bim.enable_editing_array("INVOKE_DEFAULT", item=self.item) + + def execute(self, context): + # No event in scripting / keymap exec contexts — plain enter-edit fallback. + return bpy.ops.bim.enable_editing_array("EXEC_DEFAULT", item=self.item) + + +class EnableEditingParametric(bpy.types.Operator): + """Pen-icon dispatcher: fires the gizmo group's per-feature edit operator. + + Bound to every parametric gizmo group's pen icon. The gizmo group's own + ``enable_editing_operator`` (``bim.enable_editing_door``, ``…_wall``, …) + is passed as ``feature_enable_op`` at setup time and invoked here. The + indirection lets one gizmo class serve all features without per-feature + subclasses. + + Array editing has its own dedicated entry points (the per-layer ARRAY + gizmo icons and the panel's pen button) — this dispatcher does not + branch into array edit anymore.""" + + bl_idname = "bim.enable_editing_parametric" + bl_label = "Enable Editing" + bl_description = "Edit this object's parameters" + bl_options = {"REGISTER", "UNDO"} + + feature_enable_op: bpy.props.StringProperty( + default="", + description="Operator bl_idname to invoke (e.g., 'bim.enable_editing_door').", + ) + sibling_count: bpy.props.IntProperty(default=0, options={"HIDDEN"}) + + @staticmethod + def should_show_shared_rep_dialog(*, suppress: bool, has_entity: bool, sibling_count: int) -> bool: + """Pure decision for the pre-edit warning. Returns ``True`` only when the + edit will silently mutate other elements' geometry AND the user has not + opted out of the warning for this session.""" + if suppress or not has_entity: + return False + return sibling_count > 0 + + def invoke(self, context, event): + prefs = getattr(context.window_manager, "BIMParametricEditDialogPrefs", None) + suppress = bool(prefs and prefs.suppress_shared_rep_warning) + obj = context.active_object + element = tool.Ifc.get_entity(obj) if obj else None + self.sibling_count = tool.Model.get_sibling_occurrence_count(element) if element is not None else 0 + if self.should_show_shared_rep_dialog( + suppress=suppress, has_entity=element is not None, sibling_count=self.sibling_count + ): + return context.window_manager.invoke_props_dialog(self, width=400) + return self.execute(context) + + def draw(self, context): + layout = self.layout + layout.label(text="Shared geometry", icon="ERROR") + layout.label(text=f"Geometry is shared with {self.sibling_count} other element(s).") + layout.label(text="Edits will affect them too.") + prefs = context.window_manager.BIMParametricEditDialogPrefs + layout.prop(prefs, "suppress_shared_rep_warning", text="Don't show this again for this session") + + def execute(self, context): + # Malformed ``feature_enable_op`` (missing dot) would otherwise crash + # the unpack with ValueError; treat the same as the empty-string case. + parts = self.feature_enable_op.split(".", 1) + if len(parts) != 2: + return {"CANCELLED"} + domain, opname = parts + return getattr(getattr(bpy.ops, domain), opname)("INVOKE_DEFAULT") + + +class ToggleArrayMethod(bpy.types.Operator): + """Cycle the array layer's ``method`` between OFFSET and DISTRIBUTE. + + OFFSET: each instance is placed at ``i * (x, y, z)`` from the parent — + spacing is fixed, total span scales with count. + + DISTRIBUTE: instances are spread evenly between the parent and the offset + endpoint — total span is fixed at ``(x, y, z)``, spacing scales with count. + + No-op outside an active edit lifecycle so the operator can't bypass the Finish + commit lifecycle by quietly flipping the method during a non-editing state.""" + + bl_idname = "bim.toggle_array_method" + bl_label = "Toggle Array Method" + bl_description = "Switch between fixed spacing between copies and fixed total span" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + props = _resolve_array_edit_props(context) + if props is None: + return {"CANCELLED"} + props.method = "DISTRIBUTE" if props.method == "OFFSET" else "OFFSET" + return {"FINISHED"} + + +class RemoveArrayLayerFromEdit(bpy.types.Operator, tool.Ifc.Operator): + """Discard the in-progress edit and delete the array layer being edited. + + Bound to the trash gizmo at the far right of the edit row. Reads the + currently-edited layer from ``props.editing_item_index``, cancels the + edit lifecycle (unhides children, clears editing flags), then routes through + ``bim.remove_array`` to delete the layer from the BBIM_Array pset. + Existing children of that layer are deleted as part of remove_array. + + Inherits ``tool.Ifc.Operator`` so the two chained sub-operators + (``bim.cancel_editing_array`` + ``bim.remove_array``) run inside one + transaction — one undo step instead of two, and an atomic rollback if + the second op fails (no torn state where the draft is gone but the + layer remains). The nested ``tool.Ifc.Operator`` calls detect the + existing top-level transaction (via ``IfcStore.current_transaction`` + in [bim/ifc.py:486](src/bonsai/bonsai/bim/ifc.py#L486)) and join it + rather than opening their own.""" + + bl_idname = "bim.remove_array_layer_from_edit" + bl_label = "Remove Array Layer" + bl_description = "Discard the in-progress edit and delete this array layer" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + props = _resolve_array_edit_props(context) + if props is None: + cls.poll_message_set("No active object or not editing an array") + return False + # Mirror ``_execute``'s precondition so the gizmo correctly + # disables on stale states (is_editing flag set but the index + # was cleared by drift, undo, or external mutation). + return props.editing_item_index >= 0 + + def _execute(self, context): + props = _resolve_array_edit_props(context) + if props is None: + return {"CANCELLED"} + item = props.editing_item_index + if item < 0: + return {"CANCELLED"} + # Cancel the in-progress edit first — this unhides children and + # clears is_editing / editing_item_index. Then remove the layer + # (which deletes its children and the layer's BBIM_Array entry). + # Both calls join this operator's transaction (see class docstring). + bpy.ops.bim.cancel_editing_array("EXEC_DEFAULT") + bpy.ops.bim.remove_array("EXEC_DEFAULT", item=item) + return {"FINISHED"} + + +class InputArrayCount(IntegerInputDialogMixin, bpy.types.Operator): + """Popup-dialog entry point for typing a new draft ``count`` during an + active array edit lifecycle. Bound to the world-space count gizmo in + the edit row.""" + + bl_idname = "bim.input_array_count" + bl_label = "Set Array Count" + bl_description = "Type the number of copies for this array" + bl_options = {"REGISTER", "UNDO"} + + count: bpy.props.IntProperty(name="Count", default=1, min=1) + attr_name = "count" + props_getter = staticmethod(tool.Model.get_array_props) + requires_editing = True + + +class AdjustArrayCount(bpy.types.Operator): + """Bump props.count by ``increment`` during an active element-wide edit lifecycle. + + Bound to the +/- icon gizmos flanking the count drag handle. No-ops outside + an edit lifecycle so accidentally invoking it doesn't bypass the commit lifecycle — + Finish writes IFC; this operator only touches the draft props.""" + + bl_idname = "bim.adjust_array_count" + bl_label = "Adjust Array Count" + bl_description = "Add or remove a copy from the array" + bl_options = {"REGISTER", "UNDO"} + increment: bpy.props.IntProperty() + + def execute(self, context): + props = _resolve_array_edit_props(context) + if props is None: + return {"CANCELLED"} + props.count = max(1, props.count + self.increment) + return {"FINISHED"} + + +class GizmoArrayEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): + """Viewport gizmos for the array edit lifecycle (single-layer arrays only). + Drag/+/- mutate draft props in place; commit happens at Finish.""" + + bl_idname = "OBJECT_GGT_bim_array_edition" + bl_label = "Array Editing Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + enable_editing_operator = "bim.enable_editing_array" + finish_editing_operator = "bim.finish_editing_array" + cancel_editing_operator = "bim.cancel_editing_array" + cycle_type_operator = "" + # Array is not itself arrayable from the gizmo entry — adding an array + # layer to an existing array is the panel's "+" button job. Hides the + # base-class ARRAY icon for this gizmo group only. + hide_array_button = True + # The clickable ``xN`` count label (``GizmoArrayCount``) is already the + # entry point for array edit mode from the idle state — see this class's + # docstring. The default pen icon emitted by ``BaseParametricGizmoGroup`` + # is redundant with it, and on a feature object that's ALSO array-parent + # (e.g. an arrayed duct segment) the user sees two pen icons: one for the + # feature edit, one next to the array count. Hiding this group's pen + # collapses to a single per-feature pen. + hide_pen_button = True + + # Editing row layout: validate | cancel | xN | - | + | method | trash. + # The ``count_label`` placeholder reserves the position for the + # dynamically-built ``xN`` gizmo; the +/-/method/trash icons live in + # ``feature_slots`` below and the base class assigns X positions from + # tuple order. The trash carries ``extra_gap_before`` to visually + # separate the destructive action from the routine controls. + + # Render scale for the +/- and method-toggle icons — ~70% of the standard + # 0.5 used for validate/cancel. Makes the helpers look secondary. + ICON_HELPER_SCALE = 0.35 + + feature_slots: ClassVar[tuple[IconSlot, ...]] = ( + IconSlot(name="count_label", placeholder=True), + IconSlot( + name="count_minus", + gizmo_idname="VIEW3D_GT_minus", + operator="bim.adjust_array_count", + scale=ICON_HELPER_SCALE, + color=COLOR_RED, + operator_props=(("increment", -1),), + ), + IconSlot( + name="count_plus", + gizmo_idname="VIEW3D_GT_plus", + operator="bim.adjust_array_count", + scale=ICON_HELPER_SCALE, + color=COLOR_GREEN, + operator_props=(("increment", 1),), + ), + IconSlot( + name="method", + gizmo_idname="VIEW3D_GT_cycle", + operator="bim.toggle_array_method", + scale=ICON_HELPER_SCALE, + ), + IconSlot( + name="delete", + gizmo_idname="VIEW3D_GT_trash", + operator="bim.remove_array_layer_from_edit", + scale=ICON_HELPER_SCALE, + color=COLOR_RED, + # Extra gap so the destructive action stays visually separated + # from the routine edit controls — matches the prior 0.57 gap. + extra_gap_before=0.20, + ), + ) + + # Per-layer ARRAY icons — one shown in idle state per existing array + # layer, surfaced to the right of the pen. Pre-allocated at setup time + # (Blender's gizmo API doesn't support creating gizmos on demand at draw + # time); the cap keeps the GPU resource footprint bounded. Multi-layer + # arrays with more than ``MAX_LAYER_GIZMOS`` layers fall back to the + # per-item panel UX for the overflow layers. + MAX_LAYER_GIZMOS = 8 + # Local-X spacing between successive layer icons. The start position + # is computed per-frame from peer parametric gizmo groups' idle rows + # (see ``_resolve_feature_idle_max_x``) so layer icons clear any + # feature-specific idle slots (e.g. wall's toggle-openings). + LAYER_GIZMO_SPACING = 0.4 + + dimension_gizmo_props = [ + # matrix_position must be provided even at the origin: without it, the + # base class falls back to ``Matrix.Identity(4)`` for ``base_matrix``, + # which means ``matrix_basis.col[0]`` (what GizmoDimension.draw reads + # for the line direction) becomes the object's local X — every offset + # gizmo would then render along X regardless of ``config.axis``. + # ``compose_gizmo_matrix(position, axis)`` rotates so col[0] aligns with + # the requested axis. The non-zero offsets along the OTHER two axes + # shift each gizmo's origin off the object centre so they don't pile + # up at (0,0,0). + # Each offset gizmo starts at the centre of the bounding-box face + # perpendicular to its axis (e.g. X-offset anchors at the +X face + # centre). This pulls the three arrows apart visually and makes the + # arrow tip land exactly where the next instance would appear — a + # natural read of "drag this face out by N metres to space siblings". + DimensionGizmoConfig( + attr_name="x", + axis=(1, 0, 0), + prop_name="X Offset", + min_value=-1e6, + matrix_position=lambda p: GizmoArrayEdition._axis_start(0), + ), + DimensionGizmoConfig( + attr_name="y", + axis=(0, 1, 0), + prop_name="Y Offset", + min_value=-1e6, + matrix_position=lambda p: GizmoArrayEdition._axis_start(1), + ), + DimensionGizmoConfig( + attr_name="z", + axis=(0, 0, 1), + prop_name="Z Offset", + min_value=-1e6, + matrix_position=lambda p: GizmoArrayEdition._axis_start(2), + ), + ] + + props_getter = tool.Model.get_array_props + gizmo_pref_name = "array" + + @staticmethod + def _axis_start(axis_index: int) -> Vector: + """Local-space anchor for the offset gizmo on the given axis: the + centre of the bounding-box face perpendicular to that axis on its + positive side. + + Axis 0 (X) → centre of the +X face; + Axis 1 (Y) → centre of the +Y face; + Axis 2 (Z) → centre of the +Z face. + + Returns ``Vector((0, 0, 0))`` when ``bpy.context.active_object`` is + unset or has no ``bound_box`` attribute. For 0-extent objects (Empties, + point annotations) the bbox is 8 zero-corners and the math yields the + origin naturally — same result, different path.""" + obj = bpy.context.active_object + if obj is None or not obj.bound_box: + return Vector((0.0, 0.0, 0.0)) + bbox = tool.Blender.get_object_bounding_box(obj) + center = bbox["center"] + if axis_index == 0: + return Vector((bbox["max_x"], center.y, center.z)) + if axis_index == 1: + return Vector((center.x, bbox["max_y"], center.z)) + return Vector((center.x, center.y, bbox["max_z"])) + + @classmethod + def is_element_type(cls, element: "ifcopenshell.entity_instance") -> bool: + """Poll for any array parent — multi-layer is fully supported now via + the per-layer ARRAY icons. The edit lifecycle reads ``editing_item_index`` to + pick the target layer.""" + return tool.Parametric.is_array(element) + + def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None: + """Create the world-space count label and per-layer ARRAY entry icons. + The +/- count adjusters, method toggle, and delete button live in + ``feature_slots`` and are auto-created by the base class.""" + default_color, highlight_color = self.get_decoration_colors() + # World-space count display for the edit row. Click opens a numeric + # input dialog so the user can type a value directly instead of + # clicking +/- repeatedly. Renders the same ``xN`` glyph as the + # idle-state per-layer icons for visual consistency. Position is + # reserved by the ``count_label`` placeholder slot in + # ``feature_slots``; the matrix is set per-frame below. + self.count_label_gizmo = self.gizmos.new("BIM_GT_array_layer_indicator") + self.count_label_gizmo.use_draw_scale = False + self.count_label_gizmo.color = default_color + self.count_label_gizmo.color_highlight = highlight_color + self.count_label_gizmo.alpha = 0.8 + self.count_label_gizmo.target_set_operator("bim.input_array_count") + + # Per-layer ARRAY icons — pre-allocated up to ``MAX_LAYER_GIZMOS`` and + # shown/hidden in ``_refresh_element_specific`` based on the actual + # layer count. ``BIM_GT_array_layer_indicator`` renders the 2×2-grid + # glyph PLUS an ``xN`` count label above it (one custom gizmo per + # layer keeps the label co-located with the icon at all zoom levels). + # Each binds to ``bim.array_gizmo_click(item=i)``; the dispatcher + # routes plain clicks to ``enable_editing_array(item=i)`` and + # Shift+click to ``add_array_from_feature_edit`` (new layer). + self.layer_gizmos = [] + for i in range(self.MAX_LAYER_GIZMOS): + gz = self.gizmos.new("BIM_GT_array_layer_indicator") + gz.use_draw_scale = False + gz.color = default_color + gz.color_highlight = highlight_color + gz.alpha = 0.8 + op = gz.target_set_operator("bim.array_gizmo_click") + op.item = i + # Bind layer index for the hover-publisher inside the gizmo's own + # draw method (see ``GizmoArrayLayerIndicator._publish_hover``). + gz.set_layer_index(i) + self.layer_gizmos.append(gz) + + def get_icon_y_extent(self, props) -> tuple[float, float]: + """Return the active object's bounding-box Y extents (positive, + negative absolute distances from origin) plus the standard 2× icon + clearance — same pattern feature gizmo groups use for their pen-icon + Y position. Without this, the array layer icons sit at the object + centerline while the feature pen sits at the camera-facing edge, and + the two rows end up on different local Y planes.""" + obj = bpy.context.active_object + if obj is None or not obj.bound_box: + return (0.0, 0.0) + ys = [c[1] for c in obj.bound_box] + pad = 2 * self.GIZMO_OFFSET + return (max(0.0, max(ys)) + pad, max(0.0, -min(ys)) + pad) + + def get_element_height(self, props) -> float: + """Return the visual top of the active object (bounding-box Z max) so the + array validate/cancel icons sit at the same world position as the + feature gizmo group's pen icon would on the same element. + + Why not the base behaviour: the base reads ``props.overall_height`` / + ``props.height``, but ``BIMArrayProperties`` has neither — the array layer + list doesn't know how tall the underlying door / wall / … actually is. + Using the bounding-box top is a generic proxy that works for every + arrayable IFC type, parametric or otherwise.""" + obj = bpy.context.active_object + if obj and obj.bound_box: + return tool.Blender.get_object_bounding_box(obj)["max_z"] + return 1.0 + + def _refresh_element_specific(self, context: bpy.types.Context, mw: Matrix, props) -> None: + """Position the count label and per-layer ARRAY icons. + + Idle (``not props.is_editing``): show one ARRAY icon per existing + array layer to the right of the pen. The +/-, method, and delete + slot icons stay hidden (handled by the base). + + Active edit: show the count label; hide the per-layer icons. The + +/-, method, and delete icons are positioned by the base class + via the slot layout — no manual positioning needed here.""" + icon_z = self.get_element_height(props) + self.ICON_Z_OFFSET + icon_y = self.get_icon_y_offset(context, mw) + billboard_rot = self._frame_billboard_rot + + if not props.is_editing: + self.count_label_gizmo.hide = True + layers = self._read_array_layers(context) + layer_count = min(len(layers), self.MAX_LAYER_GIZMOS) + # Feature-aware start X — pushes the layer icons past any + # feature-specific idle gizmos (e.g. wall's toggle-openings + + # offset-baseline) so they don't stack on top. + start_x = self.ICON_VALIDATE_X + self._resolve_feature_idle_max_x(context) + self.ICON_ARRAY_GAP + for i, gz in enumerate(self.layer_gizmos): + if i >= layer_count: + gz.hide = True + continue + if self.is_gizmo_hidden_by_modal(gz): + gz.hide = True + continue + gz.hide = False + # Per-layer count rendered as ``xN`` above the gizmo glyph. + gz.set_count(int(layers[i].get("count", 0))) + world_pos = mw @ Vector( + ( + start_x + i * self.LAYER_GIZMO_SPACING, + icon_y, + icon_z, + ) + ) + gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot, scale=0.5) + return + + # Active edit: hide the layer icons, show the count label. The + # +/-, method, and delete slot icons are positioned by the base. + for gz in self.layer_gizmos: + gz.hide = True + # Clickable world-space ``xN`` between cancel and minus. Mirrors the + # draft ``props.count`` so the displayed value tracks +/- drags live. + if self.is_gizmo_hidden_by_modal(self.count_label_gizmo): + self.count_label_gizmo.hide = True + else: + self.count_label_gizmo.hide = False + self.count_label_gizmo.set_count(int(props.count)) + world_pos = mw @ Vector( + ( + self.ICON_VALIDATE_X + self._slot_x_positions()["count_label"], + icon_y, + icon_z, + ) + ) + self.count_label_gizmo.matrix_basis = gizmo.billboarded_at( + world_pos, billboard_rot, scale=self.ICON_HELPER_SCALE + ) + + @staticmethod + def _read_array_layers(context: bpy.types.Context) -> list: + """Return the active object's BBIM_Array layer list (one dict per + layer), or an empty list if not resolvable. Used to decide how many + per-layer ARRAY icons to surface AND to read each layer's ``count`` + for the ``xN`` label rendered by ``GizmoArrayLayerIndicator``.""" + obj = context.active_object + if obj is None: + return [] + element = tool.Ifc.get_entity(obj) + if element is None: + return [] + data_text = ifcopenshell.util.element.get_pset(element, "BBIM_Array", "Data") + if not data_text: + return [] + try: + return json.loads(data_text) + except (ValueError, TypeError): + return [] + + @classmethod + def _resolve_feature_idle_max_x(cls, context: bpy.types.Context) -> float: + """Rightmost local-X reserved by any peer ``BaseParametricGizmoGroup`` + whose ``poll`` passes on the active element. Per-layer ARRAY icons + start one ``ICON_ARRAY_GAP`` past this so they don't stack on top of + feature-specific idle slots (e.g. wall's toggle_openings). + + Walks ``BaseParametricGizmoGroup.REGISTRY`` rather than indexing a + hardcoded per-feature table: each peer's ``_idle_row_right_edge()`` + derives from its declared ``idle_slots`` tuple, so adding an idle + icon to any feature is a one-line ``IconSlot`` append with no + coordination needed here. Defaults to 0.0 when no active object or + no peer polls visible.""" + obj = context.active_object + if obj is None: + return 0.0 + max_x = 0.0 + for peer_cls in gizmo.BaseParametricGizmoGroup.REGISTRY: + if peer_cls is cls: + continue + try: + if not peer_cls.poll(context): + continue + except Exception: + continue + edge = peer_cls._idle_row_right_edge() + if edge > max_x: + max_x = edge + return max_x + + +class GizmoArrayChild(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin): + """Two navigation icons on each array child: + + - ``VIEW3D_GT_array_parent`` (hierarchy tree) → modifier-aware select via + ``bim.array_parent_gizmo_click``: click selects the parent, Shift+click + selects the whole family, Ctrl+click selects only the children. + - ``VIEW3D_GT_array_all`` (2×2 grid) → jump to the parent and enter + array edit. + + Standalone gizmo group (not a ``BaseParametricGizmoGroup`` subclass) + because the base's ``poll`` early-returns on array children — there's + nothing to edit on a managed replica. Regenerate isn't surfaced as a + child gizmo; the panel still exposes it.""" + + bl_idname = "OBJECT_GGT_bim_array_child" + bl_label = "Array Child Helpers" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + # Local-X positions for the two icons above the child's bounding-box top. + # 0.5 spacing keeps the hit regions clear at standard view distances. + ICON_PARENT_X = 0.0 + ICON_ALL_X = 0.5 + ICON_Z_OFFSET = 0.5 + ICON_SCALE = 0.5 + + @classmethod + def poll(cls, context): + obj = tool.Blender.get_active_object(is_selected=True) + if obj is None: + return False + if not tool.Blender.are_viewport_gizmos_enabled(): + return False + if len(tool.Blender.get_selected_objects()) != 1: + return False + element = tool.Ifc.get_entity(obj) + if not element: + return False + return tool.Blender.Modifier.is_array_child(element) + + def setup(self, context: bpy.types.Context) -> None: + default_color, highlight_color = self.get_unselected_decoration_colors() + self.parent_gizmo = self.setup_icon_gizmo( + "VIEW3D_GT_array_parent", default_color, highlight_color, "bim.array_parent_gizmo_click" + ) + self.all_gizmo = self.setup_icon_gizmo( + "VIEW3D_GT_array_all", default_color, highlight_color, "bim.edit_array_from_child" + ) + + def position_gizmos(self, context: bpy.types.Context) -> None: + obj = context.active_object + if obj is None or not obj.bound_box: + return + bbox_top = max(corner[2] for corner in obj.bound_box) + billboard_rot = gizmo.get_billboard_rotation(context) + mw = obj.matrix_world + for name, x in ( + ("parent_gizmo", self.ICON_PARENT_X), + ("all_gizmo", self.ICON_ALL_X), + ): + gz = getattr(self, name) + world_pos = mw @ Vector((x, 0, bbox_top + self.ICON_Z_OFFSET)) + gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot, self.ICON_SCALE) + + +_ARRAY_LAYER_BBOX_MAX_CHILDREN = 200 + + +def draw_array_layer_children_bbox( + context: bpy.types.Context, + parent_element: ifcopenshell.entity_instance, + layer_index: int, + max_children: int = _ARRAY_LAYER_BBOX_MAX_CHILDREN, +) -> None: + """Paint a wireframe bbox around every child of one array layer in the + same 3D pass. Called inline from gizmo ``draw()`` methods so the highlight + tracks the hover cursor one-for-one — no POST_VIEW handler, no timing lag. + + Total: silently no-ops on missing pset, unparseable JSON, out-of-range + layer index, unresolvable child GUIDs, or empty child geometry.""" + if layer_index < 0: + return + data_text = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array", "Data") + if not data_text: + return + try: + layers = json.loads(data_text) + except (ValueError, TypeError): + return + if layer_index >= len(layers): + return + child_guids = layers[layer_index].get("children", []) + if not child_guids: + return + ifc_file = tool.Ifc.get() + segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [] + for guid in child_guids[:max_children]: + try: + child_element = ifc_file.by_guid(guid) + except RuntimeError: + continue + child_obj = tool.Ifc.get_object(child_element) + if child_obj is None: + continue + segments.extend(bbox_world_edges(child_obj)) + if not segments: + return + prefs = tool.Blender.get_addon_preferences() + color = prefs.decorator_color_special[:3] + draw_polyline_segments( + context, + segments, + color, + _BBOX_HIGHLIGHT_LINE_ALPHA, + _BBOX_HIGHLIGHT_LINE_WIDTH, + ) + + +class ArrayPreviewDecorator(tool.Blender.ViewportDecorator): + """Faint bbox wireframe at each future array instance during the edit lifecycle. + Pure GPU preview gated on the array's draft props — no IFC mutation.""" + + LINE_WIDTH = 1.2 + LINE_ALPHA = 0.45 + MAX_PREVIEW_INSTANCES = 200 + + def draw(self, context: bpy.types.Context) -> None: + if not tool.Blender.are_viewport_gizmos_enabled(): + return + prefs = tool.Blender.get_addon_preferences() + obj = context.active_object + if obj is None or not obj.bound_box: + return + element = tool.Ifc.get_entity(obj) + if not element or not tool.Parametric.is_array(element): + return + props = tool.Model.get_array_props(obj) + if not props.is_editing: + return + count = int(props.count) + if count <= 1 or count > self.MAX_PREVIEW_INSTANCES: + return + + segments = self._compute_segments(obj, props, count) + if not segments: + return + + color = prefs.decorator_color_selected[:3] + draw_polyline_segments(context, segments, color, self.LINE_ALPHA, self.LINE_WIDTH) + + def _compute_segments( + self, + parent_obj: bpy.types.Object, + props, + count: int, + ) -> list[tuple[tuple[float, float, float], tuple[float, float, float]]]: + """World-space (start, end) line segments for the bbox edges of + every future instance (i = 1 … count-1; i = 0 is the parent itself). + props.x/y/z are SI — the edit-lifecycle Enable hydrates them via + si_conversion, so no unit_scale multiplier here.""" + offset = Vector((props.x, props.y, props.z)) + if props.method == "DISTRIBUTE": + divider = (count - 1) if count > 1 else 1 + offset = offset / divider + + parent_mw = parent_obj.matrix_world + parent_corners = [Vector(c) for c in parent_obj.bound_box] + segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [] + for i in range(1, count): + delta = offset * i + child_mw = parent_mw.copy() + if props.use_local_space: + child_mw.translation = parent_mw @ delta + else: + child_mw.translation = parent_mw.translation + delta + world_corners = [child_mw @ corner for corner in parent_corners] + for a, b in _BBOX_EDGES: + segments.append((tuple(world_corners[a]), tuple(world_corners[b]))) + return segments + + +class ArraySelectionHighlightDecorator(tool.Blender.ViewportDecorator): + """Bounding-box overlay surfacing the array family of the selected object. + + Two activation modes: + + - **Child selected** — parent drawn in the addon's *special* + decorator color (bright accent); other siblings in the *unselected* + color at lower alpha so the parent stands out. The selected child + itself keeps Blender's standard selection outline. + - **Parent selected** (idle, not editing) — every existing child drawn + in the *unselected* color at lower alpha. The parent is already + visually flagged by Blender's selection outline. Suppressed during + an active array edit lifecycle so the live preview wireframes don't + double-draw with the existing-children overlay.""" + + LINE_WIDTH = 1.5 + PARENT_ALPHA = 0.7 + SIBLING_ALPHA = 0.35 + MAX_SIBLINGS = 200 + + def __init__(self) -> None: + self._family_cache: TokenCache = TokenCache() + + def draw(self, context: bpy.types.Context) -> None: + if not tool.Blender.are_viewport_gizmos_enabled(): + return + prefs = tool.Blender.get_addon_preferences() + obj = context.active_object + if obj is None: + return + if not obj.select_get(): + return + element = tool.Ifc.get_entity(obj) + if not element: + return + + if tool.Blender.Modifier.is_array_child(element): + self._draw_for_child(context, prefs, element, obj) + elif tool.Parametric.is_array(element): + props = tool.Model.get_array_props(obj) + if not props.is_editing: + self._draw_for_parent(context, prefs, element, obj) + + def _draw_for_child(self, context, prefs, element, obj): + family = self._resolve_family_for_child(obj, element) + if family is None: + return + parent_obj, sibling_objs = family + + parent_segments = bbox_world_edges(parent_obj) + if parent_segments: + draw_polyline_segments( + context, + parent_segments, + prefs.decorator_color_special[:3], + self.PARENT_ALPHA, + self.LINE_WIDTH, + ) + self._draw_siblings(context, prefs, sibling_objs) + + def _draw_for_parent(self, context, prefs, element, obj): + child_objs = self._resolve_children_for_parent(obj, element) + self._draw_siblings(context, prefs, child_objs) + + def _resolve_family_for_child(self, obj, element): + return self._family_cache.get_or_compute( + ("child", obj.session_uid, element.id()), + lambda: self._collect_family_from_child(element, obj), + ) + + def _resolve_children_for_parent(self, obj, element): + return ( + self._family_cache.get_or_compute( + ("parent", obj.session_uid, element.id()), + lambda: self._collect_children(element, exclude=obj), + ) + or [] + ) + + def _draw_siblings(self, context, prefs, sibling_objs): + if not sibling_objs: + return + if len(sibling_objs) > self.MAX_SIBLINGS: + sibling_objs = sibling_objs[: self.MAX_SIBLINGS] + segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [] + for sib_obj in sibling_objs: + segments.extend(bbox_world_edges(sib_obj)) + draw_polyline_segments( + context, + segments, + prefs.decorator_color_unselected[:3], + self.SIBLING_ALPHA, + self.LINE_WIDTH, + ) + + def _collect_family_from_child(self, element, obj): + pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + if not pset: + return None + parent_guid = pset.get("Parent") + if not parent_guid: + return None + try: + parent_element = tool.Ifc.get().by_guid(parent_guid) + except RuntimeError: + return None + parent_obj = tool.Ifc.get_object(parent_element) + if not parent_obj: + return None + siblings = self._collect_children(parent_element, exclude=obj, also_exclude=parent_obj) + return parent_obj, siblings + + def _collect_children(self, parent_element, exclude=None, also_exclude=None): + parent_data_text = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array", "Data") + if not parent_data_text: + return [] + try: + layers = json.loads(parent_data_text) + except (ValueError, TypeError): + return [] + children: list[bpy.types.Object] = [] + seen_ids: set[int] = set() + if exclude is not None: + seen_ids.add(id(exclude)) + if also_exclude is not None: + seen_ids.add(id(also_exclude)) + for layer in layers: + for child_guid in layer.get("children", []): + try: + child_element = tool.Ifc.get().by_guid(child_guid) + except RuntimeError: + continue + child_obj = tool.Ifc.get_object(child_element) + if child_obj is None or id(child_obj) in seen_ids: + continue + seen_ids.add(id(child_obj)) + children.append(child_obj) + return children diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index 49090e2c9f..2d4b72c7a0 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -15,12 +15,14 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# This file was modified with the assistance of an AI coding tool. from __future__ import annotations import math from math import cos, pi, radians, sin, tan -from typing import Any, Literal +from typing import Any, Literal, NamedTuple import blf import bmesh @@ -41,6 +43,11 @@ from mathutils import Matrix, Quaternion, Vector import bonsai.core.geometry import bonsai.tool as tool +from bonsai.bim.module.drawing.gizmos import ( + ARC_SEGMENTS, + DOOR_SWING_ANGLE_MAX, + DOOR_SWING_ANGLE_MIN, +) from bonsai.bim.module.drawing.helper import format_distance @@ -88,15 +95,9 @@ class ProfileDecorator: batch.draw(shader) def draw_faces(self, bm, vertices_coords): - """mutates original bm (triangulates it) - so the triangulation edges will be shown too - """ - traingulated_bm = bm - bmesh.ops.triangulate(traingulated_bm, faces=traingulated_bm.faces) - - face_indices = [[v.index for v in f.verts] for f in traingulated_bm.faces] + """Submit a non-mutating beauty-triangulated TRIS batch over ``bm``'s faces.""" faces_color = transparent_color(self.addon_prefs.decorator_color_special) - self.draw_batch("TRIS", vertices_coords, faces_color, face_indices) + tool.Blender.draw_bmesh_face_tris(bm, vertices_coords, faces_color, self.draw_batch) def __call__(self, context, get_custom_bmesh=None, draw_faces=False, exit_edit_mode_callback=None): self.addon_prefs = tool.Blender.get_addon_preferences() @@ -108,7 +109,7 @@ class ProfileDecorator: obj = context.active_object - if obj.mode != "EDIT": + if obj is None or obj.mode != "EDIT": if exit_edit_mode_callback: ProfileDecorator.uninstall() exit_edit_mode_callback() @@ -2029,3 +2030,502 @@ class BoundingBoxDecorator: else: co1.y += y_overlap / 2 + min_spacing co2.y -= y_overlap / 2 + min_spacing + + +def _fill_quads_alpha( + context: bpy.types.Context, + quads: list[ + tuple[ + tuple[float, float, float], + tuple[float, float, float], + tuple[float, float, float], + tuple[float, float, float], + ] + ], + color_rgb: tuple[float, float, float], + alpha: float, +) -> None: + """Render ``quads`` (each a 4-tuple of world-space corner verts in CCW + order) as one TRIS batch with two triangles per quad.""" + if not quads: + return + verts: list[tuple[float, float, float]] = [] + indices: list[tuple[int, int, int]] = [] + for quad in quads: + if len(quad) != 4: + continue + base = len(verts) + verts.extend(tuple(v) for v in quad) + indices.append((base, base + 1, base + 2)) + indices.append((base, base + 2, base + 3)) + if not tool.Blender.validate_shader_batch_data(verts, indices): + return + region = getattr(context, "region", None) + if region is None: + return + shader = gpu.shader.from_builtin("UNIFORM_COLOR") + shader.bind() + shader.uniform_float("color", (*color_rgb, alpha)) + batch = batch_for_shader(shader, "TRIS", {"pos": verts}, indices=indices) + gpu.state.blend_set("ALPHA") + batch.draw(shader) + gpu.state.blend_set("NONE") + + +def compute_mep_join_location(): + """Midpoint between the closest endpoint pair of two selected MEP + segments — the world location where a connecting fitting (bend / + transition) would land. Returns ``None`` when prerequisites aren't met + (wrong cardinality, mixed non-MEP).""" + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 2: + return None + for obj in selected: + element = tool.Ifc.get_entity(obj) + if element is None or not tool.System.is_mep_element(element): + return None + a_start, a_end = tool.Model.get_flow_segment_axis(selected[0]) + b_start, b_end = tool.Model.get_flow_segment_axis(selected[1]) + pairs = [(a_start, b_start), (a_start, b_end), (a_end, b_start), (a_end, b_end)] + closest = min(pairs, key=lambda p: (p[0] - p[1]).length) + return (closest[0] + closest[1]) * 0.5 + + +class MEPSegmentExtendPreviewDecorator(tool.Blender.ViewportDecorator): + """Preview line for the MEP segment extend-to-cursor gizmo. Renders one + line from the segment's current end to the cursor's projection on the + segment's local Z axis when the extend icon is hovered. Self-gates every + draw on the viewport gizmo toggle and the per-feature ``extend`` pref.""" + + draw_method = "draw_line" + + LINE_WIDTH = 1.5 + LINE_ALPHA = 0.8 + + def draw_line(self, context: bpy.types.Context) -> None: + if not tool.Blender.are_viewport_gizmos_enabled(): + return + prefs = tool.Blender.get_addon_preferences() + + active = context.active_object + if active is None: + return + selected = list(tool.Blender.get_selected_objects()) + if active not in selected or len(selected) != 1: + return + + element = tool.Ifc.get_entity(active) + if element is None: + return + + from bonsai.bim.module.model.mep import ( + GizmoDuctSegmentEdition, + GizmoPipeSegmentEdition, + ) + + if tool.Parametric.is_pipe_segment(element): + gizmo_prefs = getattr(prefs.gizmos, "pipe_segment", None) + gizmo_cls = GizmoPipeSegmentEdition + elif tool.Parametric.is_duct_segment(element): + gizmo_prefs = getattr(prefs.gizmos, "duct_segment", None) + gizmo_cls = GizmoDuctSegmentEdition + else: + return + if gizmo_prefs is None or not getattr(gizmo_prefs, "enabled", True): + return + if not self._cursor_icon_hovered(gizmo_cls, "extend_gizmo", context): + return + + current_length = max(c[2] for c in active.bound_box) if active.bound_box else 0.0 + line = self._compute_extend_preview_line(active.matrix_world, context.scene.cursor.location, current_length) + if line is None: + return + start_world, end_world = line + color = tuple(prefs.decorator_color_selected[:3]) + draw_polyline_segments( + context, + [(tuple(start_world), tuple(end_world))], + color, + self.LINE_ALPHA, + self.LINE_WIDTH, + ) + + @staticmethod + def _compute_extend_preview_line( + matrix_world: Matrix, + cursor_world: Vector, + current_length: float, + ) -> tuple[Vector, Vector] | None: + """Returns ``(current_end_world, target_end_world)`` or ``None`` when + no extend would happen (degenerate segment, or cursor on the existing + end). Target follows the cursor's raw local-Z projection unbounded — + the line stays visible past the segment origin (negative local Z) + because the user expects to see where they're pointing even when the + operator would floor it.""" + if current_length <= 0: + return None + cursor_local = matrix_world.inverted() @ cursor_world + if abs(cursor_local.z - current_length) < 1e-6: + return None + current_end_world = matrix_world @ Vector((0.0, 0.0, current_length)) + target_end_world = matrix_world @ Vector((0.0, 0.0, cursor_local.z)) + return current_end_world, target_end_world + + +class BendPreviewDecorator(tool.Blender.ViewportDecorator): + """GPU preview lines for the bend-creation flow. + + Polls on ``scene.BIMPreviewProperties.bend.is_active`` and renders the + centerline + leg projections returned by ``mep.compute_bend_preview_polylines``. + The two leg lines (segment → tangent point) show how each segment will + be shortened; the arc polyline approximates the bend curve. On invalid + geometry, draws the two rejected axes in warning colour instead so the + user sees why the bend cannot be placed. + + Installed once per Blender session from ``bim/handler.py:load_post``. + Cheap to leave running because the first thing ``draw`` does is check + ``is_active`` and return when False. + """ + + LINE_WIDTH_LEG = 1.5 + LINE_WIDTH_ARC = 2.5 + LINE_ALPHA = 0.7 + + def draw(self, context: bpy.types.Context) -> None: + scene = context.scene + preview = getattr(scene, "BIMPreviewProperties", None) + props = preview.bend if preview is not None else None + if props is None or not props.is_active: + return + ifc_file = tool.Ifc.get() + if ifc_file is None: + return + try: + start_element = ifc_file.by_id(props.start_segment_id) + end_element = ifc_file.by_id(props.end_segment_id) + except Exception: + return + 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 + if start_obj is None or end_obj is None: + return + + # Late import: decorator.py loads at addon enable but mep.py imports + # this module for the extend preview, so a module-level import would + # cycle. + from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines + + preview = cached_compute_bend_preview_polylines( + start_obj, end_obj, props.start_length, props.end_length, props.radius + ) + prefs = tool.Blender.get_addon_preferences() + + if not preview["valid"]: + warning_color = tuple(prefs.decorator_color_error[:3]) + axes = preview.get("invalid_axes") or [] + if axes: + segments = [(tuple(a), tuple(b)) for a, b in axes] + draw_polyline_segments(context, segments, warning_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC) + return + + leg_color = tuple(prefs.decorations_colour[:3]) + arc_color = tuple(prefs.decorator_color_selected[:3]) + + leg_a_far, leg_a_end = preview["leg_a"] + leg_b_far, leg_b_end = preview["leg_b"] + draw_polyline_segments( + context, + [(tuple(leg_a_far), tuple(leg_a_end)), (tuple(leg_b_far), tuple(leg_b_end))], + leg_color, + self.LINE_ALPHA, + self.LINE_WIDTH_LEG, + ) + + arc = preview["arc"] + if len(arc) >= 2: + arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)] + draw_polyline_segments(context, arc_segments, arc_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC) + + +class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator): + """GPU preview lines for the wall-fillet flow. + + Polls on ``scene.BIMPreviewProperties.wall_fillet.is_active`` and renders + the leg projections + arc + radial construction lines returned by + ``tool.Wall.compute_wall_fillet_geometry``. The two leg lines show how + each wall will be shortened to its tangent point; the arc approximates + the rounded corner; the two construction lines (arc center to each + tangent point) visually pin the radius. + + Installed once per Blender session from ``bim/handler.py:load_post`` + and uninstalled in ``bim/module/model/__init__.py:unregister``.""" + + LINE_WIDTH_LEG = 1.5 + LINE_WIDTH_ARC = 2.5 + LINE_WIDTH_CONSTRUCTION = 1.0 + LINE_ALPHA = 0.7 + CONSTRUCTION_ALPHA = 0.4 + + def draw(self, context: bpy.types.Context) -> None: + scene = context.scene + preview_props = getattr(scene, "BIMPreviewProperties", None) + props = preview_props.wall_fillet if preview_props is not None else None + if props is None or not props.is_active: + return + ifc_file = tool.Ifc.get() + if ifc_file is None: + return + try: + wall_a = ifc_file.by_id(props.wall_a_id) + wall_b = ifc_file.by_id(props.wall_b_id) + except Exception: + return + wall_a_obj = tool.Ifc.get_object(wall_a) if wall_a else None + wall_b_obj = tool.Ifc.get_object(wall_b) if wall_b else None + if wall_a_obj is None or wall_b_obj is None: + return + + geom = tool.Wall.compute_wall_fillet_geometry(wall_a_obj, wall_b_obj, props.radius) + if geom is None: + return + + prefs = tool.Blender.get_addon_preferences() + warning_color = tuple(prefs.decorator_color_error[:3]) + + if not geom["valid"]: + # Degenerate geometry paints red: invalid_radius shows legs+arc + # past the wall ends; invalid_axes shows the parallel/collinear + # axes. + if geom.get("invalid_radius"): + tangent_a = geom.get("tangent_a") + tangent_b = geom.get("tangent_b") + ref_a = tool.Wall.get_world_reference_line(wall_a_obj) + ref_b = tool.Wall.get_world_reference_line(wall_b_obj) + if tangent_a is not None and tangent_b is not None and ref_a is not None and ref_b is not None: + far_a = self._far_endpoint(ref_a, geom["intersection"]) + far_b = self._far_endpoint(ref_b, geom["intersection"]) + legs = [ + (tuple(far_a), tuple(tangent_a)), + (tuple(far_b), tuple(tangent_b)), + ] + draw_polyline_segments(context, legs, warning_color, self.LINE_ALPHA, self.LINE_WIDTH_LEG) + arc = geom.get("arc") or [] + if len(arc) >= 2: + arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)] + draw_polyline_segments(context, arc_segments, warning_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC) + elif geom.get("invalid_axes"): + axes = geom["invalid_axes"] + segments = [(tuple(a), tuple(b)) for a, b in axes] + draw_polyline_segments(context, segments, warning_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC) + return + + leg_color = tuple(prefs.decorations_colour[:3]) + arc_color = tuple(prefs.decorator_color_selected[:3]) + + # Resolved against the IFC reference line, not mesh bounds, so trimmed + # walls and openings don't shift the leg endpoints. + ref_a = tool.Wall.get_world_reference_line(wall_a_obj) + ref_b = tool.Wall.get_world_reference_line(wall_b_obj) + if ref_a is not None and ref_b is not None and geom["intersection"] is not None: + far_a = self._far_endpoint(ref_a, geom["intersection"]) + far_b = self._far_endpoint(ref_b, geom["intersection"]) + legs = [ + (tuple(far_a), tuple(geom["tangent_a"])), + (tuple(far_b), tuple(geom["tangent_b"])), + ] + draw_polyline_segments(context, legs, leg_color, self.LINE_ALPHA, self.LINE_WIDTH_LEG) + + arc = geom["arc"] + if len(arc) >= 2: + arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)] + draw_polyline_segments(context, arc_segments, arc_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC) + + # Dim construction lines from arc_center to each tangent point so + # the radius reads as concrete during drag. + arc_center = geom.get("arc_center") + if arc_center is not None: + construction = [ + (tuple(arc_center), tuple(geom["tangent_a"])), + (tuple(arc_center), tuple(geom["tangent_b"])), + ] + draw_polyline_segments( + context, construction, arc_color, self.CONSTRUCTION_ALPHA, self.LINE_WIDTH_CONSTRUCTION + ) + + @staticmethod + def _far_endpoint(reference_line, intersection): + """Endpoint of ``reference_line`` furthest from ``intersection``.""" + p1, p2 = reference_line + d1 = (p1.x - intersection[0]) ** 2 + (p1.y - intersection[1]) ** 2 + (p1.z - intersection[2]) ** 2 + d2 = (p2.x - intersection[0]) ** 2 + (p2.y - intersection[1]) ** 2 + (p2.z - intersection[2]) ** 2 + return p2 if d2 >= d1 else p1 + + +class _DoorSwingArc(NamedTuple): + """Parameters for one swing-arc draw call in door-local space.""" + + hinge_x: float + hinge_y: float + panel_width: float + x_mirror: bool + + +def _visible_arcs(door_type: str, overall_width: float, lining_offset: float) -> list[_DoorSwingArc]: + """Arc specs for the parametric door swing visualisation, agnostic of + edit-mode state so the readonly preview and the editor view stay aligned. + + Empty only for sliding-door types; unknown ``door_type`` values fall + through to a single left-hinged arc.""" + if "SLIDING" in door_type: + return [] + is_double = "DOUBLE_DOOR" in door_type + is_right_single = door_type.endswith("RIGHT") and not is_double + arcs = [ + _DoorSwingArc( + hinge_x=overall_width if is_right_single else 0.0, + hinge_y=lining_offset, + panel_width=overall_width / 2 if is_double else overall_width, + x_mirror=is_right_single, + ) + ] + if is_double: + arcs.append( + _DoorSwingArc( + hinge_x=overall_width, + hinge_y=lining_offset, + panel_width=overall_width / 2, + x_mirror=True, + ) + ) + return arcs + + +# Unit quarter-arc samples shared with the edit-mode swing gizmo so the +# readonly arc traces the same curve. Re-scaled per draw via the per-arc +# transform. +_DOOR_SWING_ARC_ANGLE_MIN_RAD = math.radians(DOOR_SWING_ANGLE_MIN) +_DOOR_SWING_ARC_ANGLE_RANGE_RAD = math.radians(DOOR_SWING_ANGLE_MAX) - _DOOR_SWING_ARC_ANGLE_MIN_RAD +_DOOR_SWING_ARC_UNIT_POINTS: tuple[Vector, ...] = tuple( + Vector( + ( + math.cos(_DOOR_SWING_ARC_ANGLE_MIN_RAD + _DOOR_SWING_ARC_ANGLE_RANGE_RAD * (_i / ARC_SEGMENTS)), + math.sin(_DOOR_SWING_ARC_ANGLE_MIN_RAD + _DOOR_SWING_ARC_ANGLE_RANGE_RAD * (_i / ARC_SEGMENTS)), + 0.0, + ) + ) + for _i in range(ARC_SEGMENTS + 1) +) + + +class DoorSwingReadonlyDecorator(tool.Blender.ViewportDecorator): + """Always-on swing-arc preview for the active Bonsai-parametric IfcDoor + when it is not currently in parametric edit mode. Matches the visual + contract of the parametric door's swing-arc gizmos so the hinge side + and opening direction can be read without entering edit mode. + + Silent-skip cases (no draw, no error): + + - active object missing / not selected / not an IfcDoor; + - door is mid-edit (the swing gizmo is already painting the arc); + - door has no ``BBIM_Door`` pset (legacy import, never edited in Bonsai).""" + + LINE_WIDTH = 1.5 + LINE_ALPHA = 0.8 + + def draw(self, context: bpy.types.Context) -> None: + obj = context.active_object + if obj is None or not obj.select_get(): + return + element = tool.Ifc.get_entity(obj) + if element is None or not element.is_a("IfcDoor"): + return + props = getattr(obj, "BIMDoorProperties", None) + if props is not None and props.is_editing: + return + pset = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Door") + if not pset: + return + data = pset.get("data_dict") + if not data: + return + door_type = data.get("door_type", "") + overall_width_project = data.get("overall_width", 0.0) + lining_offset_project = (data.get("lining_properties") or {}).get("lining_offset", 0.0) + si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + overall_width = overall_width_project * si_conversion + lining_offset = lining_offset_project * si_conversion + specs = _visible_arcs(door_type, overall_width, lining_offset) + if not specs: + return + prefs = tool.Blender.get_addon_preferences() + main_color = tuple(prefs.decorator_color_special[:3]) + mw = obj.matrix_world + segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [] + for spec in specs: + x_flip = Matrix.Scale(-1, 4, (1, 0, 0)) if spec.x_mirror else Matrix.Identity(4) + transform = ( + Matrix.Translation(Vector((spec.hinge_x, spec.hinge_y, 0.0))) + @ Matrix.Scale(spec.panel_width, 4) + @ x_flip + ) + world_main = mw @ transform + pts = [world_main @ p for p in _DOOR_SWING_ARC_UNIT_POINTS] + for i in range(len(pts) - 1): + segments.append((tuple(pts[i]), tuple(pts[i + 1]))) + draw_polyline_segments(context, segments, main_color, self.LINE_ALPHA, self.LINE_WIDTH) + + +_BBOX_EDGES = ( + (0, 1), (1, 2), (2, 3), (3, 0), + (4, 5), (5, 6), (6, 7), (7, 4), + (0, 4), (1, 5), (2, 6), (3, 7), +) # fmt: skip + + +def bbox_world_edges( + obj: bpy.types.Object, +) -> list[tuple[tuple[float, float, float], tuple[float, float, float]]]: + """Return world-space (start, end) tuples for the 12 edges of ``obj``'s + bounding box. Empty list if the object has no bound_box (e.g. Empties).""" + if not obj.bound_box: + return [] + mw = obj.matrix_world + corners = [mw @ Vector(c) for c in obj.bound_box] + return [(tuple(corners[a]), tuple(corners[b])) for a, b in _BBOX_EDGES] + + +def draw_polyline_segments( + context: bpy.types.Context, + segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]], + color_rgb: tuple[float, float, float], + alpha: float, + line_width: float, +) -> None: + """Render ``segments`` as one anti-aliased LINES batch in world space.""" + if not segments: + return + verts: list[tuple[float, float, float]] = [] + indices: list[tuple[int, int]] = [] + for start, end in segments: + base = len(verts) + verts.append(start) + verts.append(end) + indices.append((base, base + 1)) + if not tool.Blender.validate_shader_batch_data(verts, indices): + return + region = getattr(context, "region", None) + if region is None: + return + shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR") + shader.bind() + shader.uniform_float("viewportSize", (region.width, region.height)) + shader.uniform_float("lineWidth", line_width) + shader.uniform_float("color", (*color_rgb, alpha)) + batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=indices) + gpu.state.blend_set("ALPHA") + batch.draw(shader) + gpu.state.blend_set("NONE") + + +_BBOX_HIGHLIGHT_LINE_WIDTH = 1.8 +_BBOX_HIGHLIGHT_LINE_ALPHA = 0.8 diff --git a/src/bonsai/bonsai/bim/module/model/door.py b/src/bonsai/bonsai/bim/module/model/door.py index 5a14cde101..ba27524007 100644 --- a/src/bonsai/bonsai/bim/module/model/door.py +++ b/src/bonsai/bonsai/bim/module/model/door.py @@ -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_`` and ``self.gizmo_swing_arc__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 diff --git a/src/bonsai/bonsai/bim/module/model/host_add_opening_gizmo.py b/src/bonsai/bonsai/bim/module/model/host_add_opening_gizmo.py new file mode 100644 index 0000000000..64863fff22 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/model/host_add_opening_gizmo.py @@ -0,0 +1,221 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# 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.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 _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 a host element (wall / slab / roof) is the active object + and exactly one other selected object is *not* itself a host. + + Renders a single ``VIEW3D_GT_add_opening`` icon at the void object's + projected location on the host. A click dispatches ``bim.add_opening``, + which handles any element exposing the ``HasOpenings`` inverse. + + Per-frame positioning keeps the icon facing the camera as the viewport + orbits.""" + + 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: + element = _resolve_active_host(context, n_selected=2) + if element is None: + return False + # The operator itself filters on HasOpenings, but checking here keeps + # the icon from appearing on host classes that can't accept openings + # in the active IFC schema. + if not hasattr(element, "HasOpenings"): + return False + active = context.active_object + other = next(o for o in tool.Blender.get_selected_objects() if o is not active) + # Host + host pairings are claimed by host-specific gizmos (wall-join, + # extend-vertical, …) — suppress here so the add-opening icon never + # stacks on top of them. + if is_supported_host(tool.Ifc.get_entity(other)): + return False + return True + + 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: + host_obj = context.active_object + if not host_obj: + return + selected = tool.Blender.get_selected_objects() + other = next((o for o in selected if o is not host_obj), None) + if not other: + return + element = tool.Ifc.get_entity(host_obj) + if not element: + return + + if tool.Parametric.is_path_connectable_wall(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) + ) diff --git a/src/bonsai/bonsai/bim/module/model/mep.py b/src/bonsai/bonsai/bim/module/model/mep.py index df34166229..dea4129e7d 100644 --- a/src/bonsai/bonsai/bim/module/model/mep.py +++ b/src/bonsai/bonsai/bim/module/model/mep.py @@ -19,8 +19,10 @@ import collections.abc import json import re +import weakref from copy import copy -from math import cos, degrees, pi, radians, sin, tan +from math import acos, cos, degrees, pi, radians, sin, tan +from typing import ClassVar import bpy import ifcopenshell.api.geometry @@ -38,12 +40,20 @@ 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, IconActionConfig from bonsai.bim.module.model.profile import DumbProfileJoiner +from bonsai.bim.parametric_lifecycle import ParametricEditMixinBase from bonsai.tool.cad import VTX_PRECISION V = lambda *x: Vector([float(i) for i in x]) +def _segment_port(segment, at_segment_start: bool): + port_key = "start_port" if at_segment_start else "end_port" + return MEPGenerator.get_segment_data(segment).get(port_key) + + class RegenerateDistributionElement(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.regenerate_distribution_element" bl_description = ( @@ -306,7 +316,8 @@ class MEPGenerator: profile_joiner = DumbProfileJoiner() profile_joiner.set_depth(connected_obj, connected_element_length) - def get_segment_data(self, segment): + @staticmethod + def get_segment_data(segment): """returns points data is in world space""" ports = tool.System.get_ports(segment) segment_object = tool.Ifc.get_object(segment) @@ -609,36 +620,187 @@ class MEPGenerator: return obstruction, None +def find_obstruction_at_port(segment, at_segment_start): + """Return the OBSTRUCTION fitting connected at the segment's named port, or ``None``.""" + if not segment.is_a("IfcFlowSegment"): + return None + related_port = _segment_port(segment, at_segment_start) + if related_port is None: + return None + connected_port = tool.System.get_connected_port(related_port) + if connected_port is None: + return None + connected_element = tool.System.get_port_relating_element(connected_port) + if connected_element is None or not connected_element.is_a("IfcFlowFitting"): + return None + if getattr(connected_element, "PredefinedType", None) != "OBSTRUCTION": + return None + return connected_element + + +# Port-state literals returned by port_connection_state. Plain strings so they +# round-trip across module reloads and compare with ``==``. +PORT_FREE = "FREE" # No element connected — open lock state. +PORT_TERMINAL = "TERMINAL" # Terminal fitting sits here but doesn't bridge — closed lock state. +PORT_JOINED = "JOINED" # Fitting bridges this segment to a second element — unjoin state. + + +def port_connection_state(segment, at_segment_start): + """Classify a segment's named port by the shape of its connection graph. + + - ``PORT_FREE``: nothing connected. + - ``PORT_TERMINAL``: an element is connected but none of its other + ports reach a different element (dead end). + - ``PORT_JOINED``: an element is connected and at least one of its + other ports reaches a second element (bridge). + + Returns ``PORT_FREE`` defensively for non-segment or unconnected inputs.""" + if not segment.is_a("IfcFlowSegment"): + return PORT_FREE + related_port = _segment_port(segment, at_segment_start) + if related_port is None: + return PORT_FREE + connected_port = tool.System.get_connected_port(related_port) + if connected_port is None: + return PORT_FREE + connected_element = tool.System.get_port_relating_element(connected_port) + if connected_element is None: + return PORT_FREE + for other_port in tool.System.get_ports(connected_element): + if other_port == connected_port: + continue + far_port = tool.System.get_connected_port(other_port) + if far_port is None: + continue + far_element = tool.System.get_port_relating_element(far_port) + if far_element is not None and far_element != segment: + return PORT_JOINED + return PORT_TERMINAL + + +def get_connected_element_at_segment_port(segment, at_segment_start): + """Element on the far side of the named port's IfcRelConnectsPorts + (typically an IfcFlowFitting; possibly another IfcFlowSegment for direct + daisy-chains), or ``None`` if unconnected or malformed.""" + if not segment.is_a("IfcFlowSegment"): + return None + related_port = _segment_port(segment, at_segment_start) + if related_port is None: + return None + connected_port = tool.System.get_connected_port(related_port) + if connected_port is None: + return None + return tool.System.get_port_relating_element(connected_port) + + +def find_fitting_between_segments(segment_a, segment_b): + """Single IfcFlowFitting bridging segment_a and segment_b via ports, or + ``None`` if no fitting (or multiple fittings — only direct one-fitting + joins handled).""" + if not (segment_a.is_a("IfcFlowSegment") and segment_b.is_a("IfcFlowSegment")): + return None + b_ports_set = set(tool.System.get_ports(segment_b)) + for a_port in tool.System.get_ports(segment_a): + connected_port = tool.System.get_connected_port(a_port) + if connected_port is None: + continue + fitting = tool.System.get_port_relating_element(connected_port) + if fitting is None or not fitting.is_a("IfcFlowFitting"): + continue + for fitting_port in tool.System.get_ports(fitting): + other_port = tool.System.get_connected_port(fitting_port) + if other_port is not None and other_port in b_ports_set: + return fitting + return None + + +def _resolve_active_mep_segment(operator, context): + """Return the operator's target ``IfcFlowSegment`` or ``None`` after reporting. + + Reads ``operator.segment_id`` when set, otherwise the active object. Shared + dispatch shape for the port operators.""" + if operator.segment_id: + element = tool.Ifc.get().by_id(operator.segment_id) + else: + element = tool.Ifc.get_entity(context.active_object) + if element is None or not element.is_a("IfcFlowSegment"): + operator.report({"ERROR"}, "Active object is not a MEP segment.") + return None + return element + + +def _require_port_state(operator, context, required_state: str, fitting_label: str): + """Shared port-action prologue: resolve the target segment, derive + ``at_segment_start`` from ``operator.position``, and verify the named + port is in ``required_state``. Returns ``(element, at_segment_start)`` + or ``None`` after reporting; callers turn ``None`` into ``{'CANCELLED'}``. + + ``fitting_label`` (e.g. ``"joining"`` / ``"terminal"``) is interpolated + into the rejection message so each caller's phrasing reads naturally.""" + element = _resolve_active_mep_segment(operator, context) + if element is None: + return None + at_segment_start = operator.position == "START" + state = port_connection_state(element, at_segment_start) + if state != required_state: + end_label = "start" if at_segment_start else "end" + operator.report({"ERROR"}, f"No {fitting_label} fitting at the {end_label} port (state: {state}).") + return None + return element, at_segment_start + + class MEPAddObstruction(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.mep_add_obstruction" bl_label = "Add Obstruction" - bl_description = "Adds obstruction to the MEP segment" + bl_description = "Add, remove, or toggle an obstruction on the MEP segment" bl_options = {"REGISTER", "UNDO"} length: bpy.props.FloatProperty( name="Obstruction Length", description="Obstruction length in SI units", default=0.1, subtype="DISTANCE" ) segment_id: bpy.props.IntProperty(name="Segment Element ID", default=0) + position: bpy.props.EnumProperty( + name="Obstruction Position", + items=[ + ("CURSOR", "At Cursor", "Choose start/end automatically from the 3D cursor position"), + ("START", "At Start", "Pin the obstruction to the segment's start port"), + ("END", "At End", "Pin the obstruction to the segment's end port"), + ], + default="CURSOR", + ) + mode: bpy.props.EnumProperty( + name="Mode", + items=[ + ("ADD", "Add", "Create a new obstruction at the named port"), + ("REMOVE", "Remove", "Remove the obstruction at the named port"), + ("TOGGLE", "Toggle", "Add if no obstruction is present; remove if one is"), + ], + default="ADD", + ) def _execute(self, context): - if self.segment_id: - element = tool.Ifc.get().by_id(self.segment_id) + element = _resolve_active_mep_segment(self, context) + if element is None: + return {"CANCELLED"} + + if self.position == "CURSOR": + cursor_location = bpy.context.scene.cursor.location + obj = tool.Ifc.get_object(element) + axis = tool.Model.get_flow_segment_axis(obj) + at_segment_start = tool.Cad.edge_percent(cursor_location, axis) < 0.5 else: - element = tool.Ifc.get_entity(context.active_object) - if not element: - return {"CANCELLED"} + at_segment_start = self.position == "START" - if not element.is_a("IfcFlowSegment"): - self.report({"ERROR"}, f"Failed to add obstruction - object is not a MEP segment: {element.is_a()}.") - return {"CANCELLED"} + # TOGGLE resolves to ADD / REMOVE based on the current port state so the + # generator dispatch below only handles the two terminal modes. + effective_mode = self.mode + if effective_mode == "TOGGLE": + effective_mode = "REMOVE" if find_obstruction_at_port(element, at_segment_start) is not None else "ADD" - # derive obstruction position from the cursor - cursor_location = bpy.context.scene.cursor.location - obj = tool.Ifc.get_object(element) - axis = tool.Model.get_flow_segment_axis(obj) - # check if cursor is closer to the segment start - at_segment_start = tool.Cad.edge_percent(cursor_location, axis) < 0.5 - - obstruction, error_msg = MEPGenerator().add_obstruction(element, self.length, at_segment_start) + generator = MEPGenerator() + if effective_mode == "REMOVE": + _removed, error_msg = generator.remove_obstruction(element, at_segment_start) + else: + _obstruction, error_msg = generator.add_obstruction(element, self.length, at_segment_start) if error_msg: self.report({"ERROR"}, error_msg) return {"CANCELLED"} @@ -646,6 +808,196 @@ class MEPAddObstruction(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} +class MEPUnjoinAtPort(bpy.types.Operator, tool.Ifc.Operator): + """Delete the IfcFlowFitting that bridges a segment's port to a second element. + + Used when the connection at the port is in the JOINED state (the fitting + has at least one other port connecting to a different element). The + segment isn't resized — only the bridging fitting is removed. Refuses + to act on an OBSTRUCTION fitting (those are routed through + ``bim.mep_add_obstruction`` with mode=REMOVE which extends the segment + to absorb the freed length).""" + + bl_idname = "bim.mep_unjoin_at_port" + bl_label = "Unjoin MEP Segment at Port" + bl_description = "Disconnect the segment from the fitting at the named port (deletes the fitting)" + bl_options = {"REGISTER", "UNDO"} + segment_id: bpy.props.IntProperty(name="Segment Element ID", default=0) + position: bpy.props.EnumProperty( + name="Port", + items=[ + ("START", "At Start", "Operate on the segment's start port"), + ("END", "At End", "Operate on the segment's end port"), + ], + default="END", + ) + + def _execute(self, context): + resolved = _require_port_state(self, context, PORT_JOINED, "joining") + if resolved is None: + return {"CANCELLED"} + element, at_segment_start = resolved + + fitting = get_connected_element_at_segment_port(element, at_segment_start) + if fitting is None or not fitting.is_a("IfcFlowFitting"): + self.report({"ERROR"}, "Connected port does not lead to a fitting.") + return {"CANCELLED"} + if getattr(fitting, "PredefinedType", None) == "OBSTRUCTION": + self.report({"ERROR"}, "Obstruction fittings are removed via bim.mep_add_obstruction (mode=REMOVE).") + return {"CANCELLED"} + + fitting_obj = tool.Ifc.get_object(fitting) + if fitting_obj is None: + self.report({"ERROR"}, "Fitting has no Blender object.") + return {"CANCELLED"} + tool.Geometry.delete_ifc_object(fitting_obj) + return {"FINISHED"} + + +class MEPRemoveTerminalFitting(bpy.types.Operator, tool.Ifc.Operator): + """Remove the terminal fitting at a segment's named port. + + Dispatches by fitting type: OBSTRUCTION fittings go through + ``MEPGenerator.remove_obstruction`` (extends the segment back to absorb + the freed length); other terminal fittings go through the standard + delete path.""" + + bl_idname = "bim.mep_remove_terminal_fitting" + bl_label = "Remove Terminal Fitting" + bl_description = "Remove the fitting at the segment's named port" + bl_options = {"REGISTER", "UNDO"} + segment_id: bpy.props.IntProperty(name="Segment Element ID", default=0) + position: bpy.props.EnumProperty( + name="Port", + items=[ + ("START", "At Start", "Operate on the segment's start port"), + ("END", "At End", "Operate on the segment's end port"), + ], + default="END", + ) + + def _execute(self, context): + resolved = _require_port_state(self, context, PORT_TERMINAL, "terminal") + if resolved is None: + return {"CANCELLED"} + element, at_segment_start = resolved + + fitting = get_connected_element_at_segment_port(element, at_segment_start) + if fitting is None: + self.report({"ERROR"}, "Terminal port does not lead to a fitting.") + return {"CANCELLED"} + + # OBSTRUCTION predefined-type value is IFC4+; IFC2X3 files fall through + # to plain deletion which is the correct behaviour for non-obstruction + # terminals. + is_obstruction = fitting.is_a("IfcFlowFitting") and getattr(fitting, "PredefinedType", None) == "OBSTRUCTION" + if is_obstruction: + _removed, error_msg = MEPGenerator().remove_obstruction(element, at_segment_start) + if error_msg: + self.report({"ERROR"}, error_msg) + return {"CANCELLED"} + return {"FINISHED"} + + fitting_obj = tool.Ifc.get_object(fitting) + if fitting_obj is None: + self.report({"ERROR"}, "Fitting has no Blender object.") + return {"CANCELLED"} + tool.Geometry.delete_ifc_object(fitting_obj) + return {"FINISHED"} + + +class MEPUnjoinPair(bpy.types.Operator, tool.Ifc.Operator): + """Delete the IfcFlowFitting joining two selected MEP segments. + + Removes the fitting; segments are left in place for the user to reposition.""" + + bl_idname = "bim.mep_unjoin_pair" + bl_label = "Unjoin MEP Segments" + bl_description = "Delete the fitting joining the two selected MEP segments" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not _n_mep_selected(2): + cls.poll_message_set("Select exactly 2 MEP segments joined by a fitting.") + return False + return True + + def _execute(self, context): + selected_objs = tool.Blender.get_selected_objects() + elements = [tool.Ifc.get_entity(o) for o in selected_objs] + if any(e is None or not e.is_a("IfcFlowSegment") for e in elements): + self.report({"ERROR"}, "Both selected objects must be MEP segments.") + return {"CANCELLED"} + fitting = find_fitting_between_segments(elements[0], elements[1]) + if fitting is None: + self.report({"ERROR"}, "No single fitting joins the selected segments.") + return {"CANCELLED"} + if getattr(fitting, "PredefinedType", None) == "OBSTRUCTION": + self.report({"ERROR"}, "Obstruction fittings are removed via bim.mep_add_obstruction (mode=REMOVE).") + return {"CANCELLED"} + fitting_obj = tool.Ifc.get_object(fitting) + if fitting_obj is None: + self.report({"ERROR"}, "Fitting has no Blender object.") + return {"CANCELLED"} + tool.Geometry.delete_ifc_object(fitting_obj) + return {"FINISHED"} + + +class SelectMEPPathMembers(bpy.types.Operator): + """Replace the selection with every MEP element reachable from the active + one via IfcRelConnectsPorts — the entire connected distribution network.""" + + bl_idname = "bim.select_mep_path_members" + bl_label = "Select MEP Path Members" + bl_description = ( + "Select every MEP element connected to the active element via its ports — the whole connected network" + ) + 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 tool.System.is_mep_element(element): + cls.poll_message_set("Active object must be an MEP element (IfcFlowSegment / IfcFlowFitting).") + return False + return True + + def execute(self, context): + active = context.active_object + element = tool.Ifc.get_entity(active) + try: + members = tool.System.walk_connected_mep_elements(element) + except Exception as e: + self.report({"ERROR"}, f"Path traversal failed: {e}") + return {"CANCELLED"} + if not members: + self.report({"INFO"}, "No connected MEP elements found.") + return {"FINISHED"} + + objs_to_select: list[bpy.types.Object] = [] + for member_element in members: + obj = tool.Ifc.get_object(member_element) + if obj is not None: + objs_to_select.append(obj) + if not objs_to_select: + self.report({"WARNING"}, "Connected elements have no Blender objects to select.") + return {"CANCELLED"} + + bpy.ops.object.select_all(action="DESELECT") + for obj in objs_to_select: + obj.select_set(True) + context.view_layer.objects.active = active + + if len(objs_to_select) > 1: + self.report({"INFO"}, f"Selected {len(objs_to_select)} MEP elements on this path.") + return {"FINISHED"} + + class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.mep_add_transition" bl_label = "Add Transition" @@ -715,11 +1067,7 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator): start_object.matrix_world.to_quaternion().rotation_difference(end_object_rotation).to_euler().z ) - def is_multiple_of_pi(value): - n = round(value / pi) - return tool.Cad.is_x(abs(value - n * pi), 0) - - if not is_multiple_of_pi(rotation_difference_z): + if not tool.Cad.is_multiple_of_pi(rotation_difference_z): self.report( {"ERROR"}, "There is some rotation difference between profiles by local Z axis: " @@ -728,8 +1076,8 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator): return {"CANCELLED"} # setup start / end points - start_segment_data = MEPGenerator().get_segment_data(start_element) - end_segment_data = MEPGenerator().get_segment_data(end_element) + start_segment_data = MEPGenerator.get_segment_data(start_element) + end_segment_data = MEPGenerator.get_segment_data(end_element) points_ports_map = { start_segment_data["start_point"]: start_segment_data["start_port"], start_segment_data["end_point"]: start_segment_data["end_port"], @@ -905,12 +1253,35 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): radius: bpy.props.FloatProperty( name="Bend Inner Radius", description="Bend inner radius in SI units", default=0.2, subtype="DISTANCE", min=0 ) + editing_bend_id: bpy.props.IntProperty( + name="Existing Bend Element ID", + default=0, + description="When non-zero, delete this bend fitting + its port connections before creating the new bend.", + ) def _execute(self, context): start_element, end_element = None, None ifc_file = tool.Ifc.get() si_conversion = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) + # Re-edit path: delete the old bend fitting + its port connections so + # the segments are free to be re-joined by a fresh bend below. Runs + # inside the same operator transaction as the recreate so a single + # Ctrl+Z rewinds both. + if self.editing_bend_id: + try: + old_bend = ifc_file.by_id(self.editing_bend_id) + except RuntimeError: + old_bend = None + if old_bend is not None: + for port in tool.System.get_ports(old_bend): + rel = next(iter(port.ConnectedFrom + port.ConnectedTo), None) + if rel is not None and rel.is_a("IfcRelConnectsPorts"): + bonsai.core.geometry.remove_connection(tool.Geometry, connection=rel) + old_bend_obj = tool.Ifc.get_object(old_bend) + if old_bend_obj is not None: + tool.Geometry.delete_ifc_object(old_bend_obj) + if self.start_segment_id and self.end_segment_id: start_element = ifc_file.by_id(self.start_segment_id) end_element = ifc_file.by_id(self.end_segment_id) @@ -937,11 +1308,7 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): start_object.matrix_world.to_quaternion().rotation_difference(end_object_rotation).to_euler() ) - def is_multiple_of_pi(value): - n = round(value / pi) - return tool.Cad.is_x(abs(value - n * pi), 0) - - if not is_multiple_of_pi(rotation_difference.z): + if not tool.Cad.is_multiple_of_pi(rotation_difference.z): error_msg = ( "There is some rotation difference between profiles by local Z axis: " f"{round(degrees(rotation_difference.z))} deg, adding a bend is not possible." @@ -985,8 +1352,8 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): # setup start / end points start_object_rotation = start_object.matrix_world.to_quaternion().to_matrix() - start_segment_data = MEPGenerator().get_segment_data(start_element) - end_segment_data = MEPGenerator().get_segment_data(end_element) + start_segment_data = MEPGenerator.get_segment_data(start_element) + end_segment_data = MEPGenerator.get_segment_data(end_element) # use id() to match by the exact vector objects and not by their values # since vectors position could match points_ports_map = { @@ -1139,6 +1506,44 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): ) return {"ERROR"} + # FIXME(#8106): capture the bend centerline in world space BEFORE + # the segments are extended — once DumbProfileJoiner.join_E reshapes + # them, the axes no longer reach the original intersection and + # compute_bend_preview_polylines would reconstruct the wrong arc. + # The arc points are consumed at the end of _execute to tessellate + # the fitting and bypass the IfcSweptDiskSolid round-trip bug. Drop + # this capture once https://github.com/IfcOpenShell/IfcOpenShell/issues/8106 + # is fixed and mep_bend_shape's output is round-trip-safe. + # ``self.start_length`` / ``self.end_length`` / ``self.radius`` are in + # scene (SI) units and ``compute_bend_preview_polylines`` works in + # world / scene coordinates, so no si_conversion division here. + # The bend's swept-disk centerline isn't at the user's "inner radius" + # — it's offset by half the profile width (matches MEPAddBend's + # ``ref_point_radius = self.radius + profile_dim[lateral_axis]``). + # Without that offset the bend's leg endpoints fall short of the + # extended segment by ``profile_dim * tan(angle/2)`` and a visible + # gap appears at each joint. + _bend_centerline_world = compute_bend_preview_polylines( + start_object, + end_object, + self.start_length, + self.end_length, + self.radius + profile_dim[lateral_axis], + arc_resolution=24, + ) + if _bend_centerline_world["valid"]: + # The bend fitting covers the straight start_length leg, the arc, + # and the straight end_length leg — its centerline runs from the + # segment's new endpoint through the arc to the other segment's + # new endpoint. ``leg_a[1]`` and ``leg_b[1]`` are those endpoints. + _bend_centerline_arc = ( + [_bend_centerline_world["leg_a"][1]] + + list(_bend_centerline_world["arc"]) + + [_bend_centerline_world["leg_b"][1]] + ) + else: + _bend_centerline_arc = None + DumbProfileJoiner().join_E(start_object, start_segment_extend_point, start_connection) DumbProfileJoiner().join_E(end_object, end_segment_extend_point, end_connection) @@ -1261,5 +1666,1287 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): ifcopenshell.api.system.connect_port(ifc_file, port1=ports[0], port2=start_port, direction="NOTDEFINED") ifcopenshell.api.system.connect_port(ifc_file, port1=ports[1], port2=end_port, direction="NOTDEFINED") + # FIXME(#8106): IfcSweptDiskSolid representations from mep_bend_shape + # are geometrically correct but fail to round-trip through the + # OpenCascade geometry kernel — they don't load back after save. + # Until the upstream parser / kernel fix lands at + # https://github.com/IfcOpenShell/IfcOpenShell/issues/8106, replace + # the swept-disk with a hand-tessellated IfcTriangulatedFaceSet + # built by sweeping the segment's profile along the bend centerline + # captured before segment extension. Drop this branch + the + # _tessellate_bend_fitting helper once the upstream fix lands. + if _bend_centerline_arc is not None: + # Seed the sweep basis from start_object's local X / Y axes so + # asymmetric IfcRectangleProfileDef ducts land with XDim / YDim + # on the same axes the segment's profile actually uses + # (parallel transport then preserves that alignment around the arc). + start_rotation = start_object.matrix_world.to_3x3() + initial_basis = ( + (start_rotation @ Vector((1.0, 0.0, 0.0))), + (start_rotation @ Vector((0.0, 1.0, 0.0))), + ) + self._tessellate_bend_fitting( + fitting_obj, bend_type, _bend_centerline_arc, profile, si_conversion, initial_basis + ) + self.report({"INFO"}, f"Success!.. kind of. The angle was {round(bend_data['angle'])}") return {"FINISHED"} + + @staticmethod + def _tessellate_bend_fitting( + fitting_obj: bpy.types.Object, + bend_type: ifcopenshell.entity_instance, + arc_points_world: "list[Vector]", + profile: ifcopenshell.entity_instance, + si_conversion: float, + initial_basis: "tuple[Vector, Vector] | None" = None, + ) -> None: + """Hand-mesh the bend body and replace the bend type's representation + with an ``IfcTessellatedFaceSet`` so the occurrence inherits the + tessellation and the swept-disk path never reaches a saved file. + + The mesh is computed in the occurrence's local frame + (``inv(fitting_obj.matrix_world)``) — occurrence world geometry = + ``fitting_obj.matrix_world @ type_local_mesh``, so building in + ``inv(M) @ P`` and storing on the type lands the occurrence at the + intended world arc points ``P``. We target the type rather than the + occurrence because the swept-disk representation lives on the type; + the occurrence inherits and has no own representation to update.""" + profile_2d_ifc = _bend_profile_cross_section(profile) + if profile_2d_ifc is None: + return + # ``_bend_profile_cross_section`` reads ``profile.Radius`` / ``XDim`` / + # ``YDim`` straight from the IFC entity, which are in IFC native units + # (millimetres for an mm file). Blender mesh data lives in scene + # (SI / metres) units, so apply the same ``* si_conversion`` + # conversion ``MEPAddBend`` uses for ``profile_dim``. + profile_2d_scene = [(x * si_conversion, y * si_conversion) for x, y in profile_2d_ifc] + + type_obj = tool.Ifc.get_object(bend_type) + if type_obj is None: + return + + inv_matrix = fitting_obj.matrix_world.inverted() + centerline_local = [inv_matrix @ p for p in arc_points_world] + + # ``initial_basis`` comes from the source segment's matrix_world (world + # directions). Express it in the fitting's local frame too so the + # rectangle's XDim / YDim land on the segment's local +X / +Y after + # the occurrence's matrix_world transform. + local_basis: tuple[Vector, Vector] | None = None + if initial_basis is not None: + inv_3x3 = inv_matrix.to_3x3() + local_basis = ((inv_3x3 @ initial_basis[0]), (inv_3x3 @ initial_basis[1])) + + verts_local, faces = _sweep_profile_along_polyline(centerline_local, profile_2d_scene, local_basis) + + # Build the mesh on a throwaway object that ``export_mesh_to_tessellation`` + # can read. The helper iterates Blender's ``split_by_loose_parts`` and + # would delete the meshes it consumes, so we don't reuse type_obj.data + # here (replace_object_ifc_representation below refreshes type_obj from + # the new IFC representation). + import bmesh + + source_mesh = bpy.data.meshes.new("BendTessSource") + source_mesh.from_pydata([tuple(v) for v in verts_local], [], faces) + source_mesh.update() + + # _sweep_profile_along_polyline leaves face winding to the caller — + # recalc_face_normals orients them outward consistently for the closed + # bend tube (sides + start cap + end cap). + bm = bmesh.new() + bm.from_mesh(source_mesh) + bmesh.ops.recalc_face_normals(bm, faces=bm.faces) + bm.to_mesh(source_mesh) + bm.free() + source_mesh.update() + + source_obj = bpy.data.objects.new("BendTessSource", source_mesh) + + ifc_file = tool.Ifc.get() + body = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW") + try: + new_rep = tool.Geometry.export_mesh_to_tessellation(source_obj, body) + tool.Model.replace_object_ifc_representation(body, type_obj, new_rep) + finally: + bpy.data.objects.remove(source_obj) + if source_mesh.users == 0: + bpy.data.meshes.remove(source_mesh) + + +def _n_mep_selected(n: int) -> bool: + selected = tool.Blender.get_selected_objects() + if len(selected) != n: + return False + for selected_obj in selected: + element = tool.Ifc.get_entity(selected_obj) + if element is None or not tool.System.is_mep_element(element): + return False + return True + + +def segments_are_parallel(start_object, end_object) -> bool: + """True iff the two MEP segments' axes are parallel (or collinear).""" + start_axis = tool.Model.get_flow_segment_axis(start_object) + end_axis = tool.Model.get_flow_segment_axis(end_object) + return tool.Cad.are_edges_parallel(start_axis, end_axis) + + +def validate_bend_preconditions(start_element, end_element) -> str | None: + """Return a user-facing error string when ``MEPAddBend`` would reject the + two segments, or ``None`` when the bend is supported. Mirrors the early + checks in ``MEPAddBend._execute`` so callers (preview enable, gizmo + poll, dispatcher) can surface the same diagnostic immediately instead + of after the user tunes a preview that cannot commit.""" + start_type = ifcopenshell.util.element.get_type(start_element) + end_type = ifcopenshell.util.element.get_type(end_element) + if not start_type or not end_type or start_type != end_type: + return "Segments types do not match or one of the segments doesn't have type which is required for a bend." + profile = tool.Model.get_flow_segment_profile(start_element) + if profile is None: + return "Segment profile could not be resolved." + if not profile.is_a("IfcRectangleProfileDef") and not profile.is_a("IfcCircleProfileDef"): + return ( + "For now Only IfcRectangleProfileDef/IfcCircleProfileDef profiles supported for a bend, " + f"the segments are {profile.is_a()}" + ) + return None + + +class MEPJoinSegments(bpy.types.Operator): + """Dispatcher: join two MEP segments via transition (parallel) or bend + (non-parallel). + + ``MEPAddTransition`` rejects non-parallel inputs; ``MEPAddBend`` rejects + parallel inputs (its axis-intersection step is undefined for parallel + lines). Collapsing them under one click target removes a per-frame + question the user shouldn't have to answer.""" + + bl_idname = "bim.mep_join_segments" + bl_label = "Join MEP Segments" + bl_description = "Join the two selected MEP segments — transition if parallel, bend if not" + 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 join.") + 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"} + if segments_are_parallel(active, other): + return bpy.ops.bim.mep_add_transition() + return bpy.ops.bim.enable_bend_preview() + + +def _is_bend_fitting(element) -> bool: + """True iff ``element`` is an ``IfcFlowFitting`` whose type carries + ``PredefinedType="BEND"``.""" + if element is None or not element.is_a("IfcFlowFitting"): + return False + element_type = ifcopenshell.util.element.get_type(element) + if element_type is None: + return False + return getattr(element_type, "PredefinedType", None) == "BEND" + + +def _intersection_past_near(intersection: Vector, near: Vector, far: Vector) -> bool: + """True iff ``intersection`` lies past ``near`` away from ``far`` — i.e. + on the bend-corner side of the segment. Used to reject configurations + where the axes meet INSIDE one of the segments (the bend fitting + wouldn't physically fit).""" + base = near - far + if base.length < 1e-6: + return False + return (intersection - near).dot(base.normalized()) > 1e-6 + + +def compute_bend_preview_polylines( + start_object, + end_object, + start_length: float, + end_length: float, + radius: float, + arc_resolution: int = 24, +): + """Compute the centerline polylines visualising a bend between two MEP + segments WITHOUT mutating IFC or Blender state. + + Returns a dict with keys: + + - ``"valid"`` (bool) — False for parallel / collinear / degenerate axes + and for in-segment intersections. + - ``"leg_a"`` / ``"leg_b"`` — ``(far_endpoint, tangent_point)`` per + segment, ``None`` when invalid. + - ``"arc"`` — ``arc_resolution + 1`` points sampling the bend arc. + - ``"invalid_axes"`` (when invalid + in-segment) — pair of + ``(far_endpoint, intersection)`` so the decorator can highlight the + rejected axes in warning colour.""" + from mathutils import Quaternion + + start_axis = tool.Model.get_flow_segment_axis(start_object) + end_axis = tool.Model.get_flow_segment_axis(end_object) + + intersection = tool.Cad.intersect_edges(start_axis, end_axis) + if intersection is None: + return {"valid": False, "leg_a": None, "leg_b": None, "arc": []} + intersection_point = intersection[0] + + start_near, start_far = tool.Cad.closest_and_furthest_vectors(intersection_point, start_axis) + end_near, end_far = tool.Cad.closest_and_furthest_vectors(intersection_point, end_axis) + + # The intersection MUST lie outside both segments — past the near-endpoint + # on the bend-corner side. When it lands inside a segment the tangent + # points overlap the segment itself and the arc sweeps through a + # degenerate half-circle. + invalid_axes = [ + (start_far, intersection_point), + (end_far, intersection_point), + ] + + if not _intersection_past_near(intersection_point, start_near, start_far): + return { + "valid": False, + "reason": "intersection_inside_start", + "leg_a": None, + "leg_b": None, + "arc": [], + "invalid_axes": invalid_axes, + } + if not _intersection_past_near(intersection_point, end_near, end_far): + return { + "valid": False, + "reason": "intersection_inside_end", + "leg_a": None, + "leg_b": None, + "arc": [], + "invalid_axes": invalid_axes, + } + + dir_into_start = start_near - intersection_point + dir_into_end = end_near - intersection_point + if dir_into_start.length < 1e-6 or dir_into_end.length < 1e-6: + return {"valid": False, "leg_a": None, "leg_b": None, "arc": []} + dir_into_start.normalize() + dir_into_end.normalize() + + cos_angle = max(-1.0, min(1.0, dir_into_start.dot(dir_into_end))) + angle = acos(cos_angle) + bend_angle = pi - angle + if bend_angle < 1e-3 or bend_angle > pi - 1e-3: + return {"valid": False, "leg_a": None, "leg_b": None, "arc": []} + + tangent_offset = radius * tan(bend_angle / 2) + leg_a_tangent = intersection_point + dir_into_start * tangent_offset + leg_b_tangent = intersection_point + dir_into_end * tangent_offset + + leg_a_endpoint = leg_a_tangent + dir_into_start * start_length + leg_b_endpoint = leg_b_tangent + dir_into_end * end_length + + plane_normal = dir_into_start.cross(dir_into_end) + if plane_normal.length < 1e-6: + return {"valid": False, "leg_a": None, "leg_b": None, "arc": []} + plane_normal.normalize() + perp_to_start = plane_normal.cross(dir_into_start).normalized() + if perp_to_start.dot(dir_into_end) < 0: + perp_to_start = -perp_to_start + arc_center = leg_a_tangent + perp_to_start * radius + + v_a = leg_a_tangent - arc_center + v_b = leg_b_tangent - arc_center + sweep_axis = plane_normal if v_a.cross(v_b).dot(plane_normal) > 0 else -plane_normal + + arc_points = [] + for i in range(arc_resolution + 1): + t = i / arc_resolution + q = Quaternion(sweep_axis, bend_angle * t) + arc_points.append(arc_center + (q @ v_a)) + + return { + "valid": True, + "leg_a": (start_far, leg_a_endpoint), + "leg_b": (end_far, leg_b_endpoint), + "arc": arc_points, + } + + +# Single-entry memo: the bend-preview decorator and GizmoBendPreview both call +# the polyline math every redraw, so without this the quaternion sweep + axis +# intersection run twice per frame. Only one bend preview is active at a time +# (enforced by BIMBendPreviewProperties.is_active), so single-entry is enough. +_bend_preview_memo: "tuple[tuple, dict] | None" = None + + +def cached_compute_bend_preview_polylines( + start_object, + end_object, + start_length: float, + end_length: float, + radius: float, + arc_resolution: int = 24, +): + """Per-frame-safe wrapper over ``compute_bend_preview_polylines``. + + Reuses the most recent result when inputs (object identities, world + matrices, the three tuned dimensions, arc resolution, and the global IFC + geometry generation) are unchanged. The commit operator path still uses + ``compute_bend_preview_polylines`` directly — there's no point caching a + one-shot call.""" + global _bend_preview_memo + key = ( + start_object.name, + tuple(map(tuple, start_object.matrix_world)), + end_object.name, + tuple(map(tuple, end_object.matrix_world)), + start_length, + end_length, + radius, + arc_resolution, + tool.Parametric.get_geom_generation(), + ) + if _bend_preview_memo is not None and _bend_preview_memo[0] == key: + return _bend_preview_memo[1] + result = compute_bend_preview_polylines(start_object, end_object, start_length, end_length, radius, arc_resolution) + _bend_preview_memo = (key, result) + return result + + +def _bend_profile_cross_section(profile, n_circle: int = 16) -> "list[tuple[float, float]] | None": + """Return the segment's cross-section profile as a list of 2D points in + the (right, up) sweep plane. Circle → ``n_circle`` evenly-spaced ring + points; rectangle → 4 corners. Returns ``None`` for unsupported types.""" + if profile.is_a("IfcCircleProfileDef"): + r = profile.Radius + return [(r * cos(2 * pi * i / n_circle), r * sin(2 * pi * i / n_circle)) for i in range(n_circle)] + if profile.is_a("IfcRectangleProfileDef"): + hx, hy = profile.XDim / 2, profile.YDim / 2 + return [(-hx, -hy), (hx, -hy), (hx, hy), (-hx, hy)] + return None + + +def _sweep_profile_along_polyline( + centerline: "list[Vector]", + profile_2d: "list[tuple[float, float]]", + initial_basis: "tuple[Vector, Vector] | None" = None, +) -> "tuple[list[Vector], list[tuple[int, ...]]]": + """Sweep a 2D profile along a 3D centerline polyline. Returns + ``(verts, faces)``. + + Uses parallel-transport framing: the (right, up) basis at each ring is + obtained by rotating the previous ring's basis by the minimum rotation + that maps the previous tangent to the current one. This avoids the + abrupt twist a fixed world-reference basis introduces when the tangent + crosses the reference axis. Face winding is left to the caller to + correct via ``bmesh.ops.recalc_face_normals`` on the resulting mesh — + cheaper than puzzling through the chirality here. + + ``initial_basis`` is the (right, up) world-direction pair at the first + ring. Asymmetric rectangular profiles need it set from the source + segment's matrix_world so XDim / YDim land on the right segment-local + axes; circles + symmetric rectangles get the same shape either way.""" + verts: list[Vector] = [] + n_profile = len(profile_2d) + n_rings = len(centerline) + + def _tangent_at(i: int) -> Vector: + if i == 0: + return (centerline[1] - centerline[0]).normalized() + if i == n_rings - 1: + return (centerline[-1] - centerline[-2]).normalized() + return (centerline[i + 1] - centerline[i - 1]).normalized() + + first_tangent = _tangent_at(0) + if initial_basis is not None: + right, up = initial_basis + right = right.normalized() + up = up.normalized() + else: + # Fallback when the caller has no opinion: stable world-Z reference. + up_ref = Vector((0.0, 0.0, 1.0)) if abs(first_tangent.z) < 0.95 else Vector((1.0, 0.0, 0.0)) + right = first_tangent.cross(up_ref).normalized() + up = right.cross(first_tangent).normalized() + prev_tangent = first_tangent + + for i, p in enumerate(centerline): + current_tangent = _tangent_at(i) + if i > 0: + axis = prev_tangent.cross(current_tangent) + if axis.length > 1e-6: + axis.normalize() + angle = prev_tangent.angle(current_tangent) + rot = Matrix.Rotation(angle, 3, axis) + right = (rot @ right).normalized() + up = (rot @ up).normalized() + for s_x, s_y in profile_2d: + verts.append(p + right * s_x + up * s_y) + prev_tangent = current_tangent + + faces: list[tuple[int, ...]] = [] + for ring_i in range(n_rings - 1): + for j in range(n_profile): + v0 = ring_i * n_profile + j + v1 = ring_i * n_profile + ((j + 1) % n_profile) + v2 = (ring_i + 1) * n_profile + ((j + 1) % n_profile) + v3 = (ring_i + 1) * n_profile + j + faces.append((v0, v1, v2, v3)) + + # End caps: fan triangulation from vertex 0 of each terminal ring. + for j in range(1, n_profile - 1): + faces.append((0, j + 1, j)) + last_start = (n_rings - 1) * n_profile + for j in range(1, n_profile - 1): + faces.append((last_start, last_start + j, last_start + j + 1)) + + return verts, faces + + +# --- MEP segment parametric edit + cursor-anchored operators --------------- + + +def _segment_world_length(obj: bpy.types.Object) -> float: + """World-space length of an MEP segment's extrusion axis.""" + start, end = tool.Model.get_flow_segment_axis(obj) + return (end - start).length + + +def _preview_segment_via_scale( + obj: bpy.types.Object, + props_length: float, + snap_length: float, + snap_object_scale_z: float, +) -> None: + """Scale obj along local Z so the visible segment matches ``props_length`` + without touching IFC. + + Composes correctly with a non-identity pre-edit ``obj.scale.z``: the + mesh's local-Z extent is ``snap_length / snap_object_scale_z``, so the + new scale.z is ``props_length / mesh_local_length``.""" + if snap_length < 1e-6 or snap_object_scale_z < 1e-6: + return + mesh_local_length = snap_length / snap_object_scale_z + obj.scale.z = max(props_length, 0.01) / mesh_local_length + + +def _restore_segment_scale_to(obj: bpy.types.Object, scale_z: float) -> None: + """Restore obj's local-Z scale. Cancel passes the pre-edit + ``snap_object_scale_z``; finish passes ``1.0`` because ``set_depth`` has + already rebuilt the mesh 1:1 with the new IFC length.""" + obj.scale.z = scale_z + + +def regenerate_pipe_segment_mesh_from_props(obj: bpy.types.Object) -> None: + """Live-preview hook for ``BIMPipeSegmentProperties.length`` drags.""" + props = tool.Model.get_pipe_segment_props(obj) + _preview_segment_via_scale(obj, props.length, props.snap_length, props.snap_object_scale_z) + props.mesh_dirty = True + + +def regenerate_duct_segment_mesh_from_props(obj: bpy.types.Object) -> None: + """Live-preview hook for ``BIMDuctSegmentProperties.length`` drags.""" + props = tool.Model.get_duct_segment_props(obj) + _preview_segment_via_scale(obj, props.length, props.snap_length, props.snap_object_scale_z) + props.mesh_dirty = True + + +def _restore_segment_mesh_if_dirty(props, obj: bpy.types.Object) -> None: + """Restore obj's preview scale to the pre-edit value if dirty. + + Restoring to ``snap_object_scale_z`` (not 1.0) avoids zeroing a user's + non-identity pre-edit scale.""" + if not props.mesh_dirty: + return + _restore_segment_scale_to(obj, props.snap_object_scale_z) + props.mesh_dirty = False + + +class _MEPSegmentEditMixin(ParametricEditMixinBase): + """MEP segment edit lifecycle (length-only). + + Segment editing has no BBIM pset — the length lives in the IFC + extrusion depth and is rewritten by ``DumbProfileJoiner.set_depth``. The + ``snap_object_scale_z`` field on the PropertyGroup records pre-edit + scale so Cancel and no-op Finish restore the segment exactly to its + pre-edit visual state. Finish dispatches ``bim.regenerate_distribution_element`` + on length-change to re-align adjacent fittings.""" + + pset_name = "" # MEP segments carry no BBIM_ pset. + + @classmethod + def _enable_one(cls, obj: bpy.types.Object) -> None: + resolved = cls._resolve(obj) + if resolved is None: + return + _element, props = resolved + # Commit any pre-edit matrix_world drift before snap_length is captured + # from _segment_world_length. Otherwise set_depth at Finish would write + # representation coords relative to a stale ObjectPlacement. + cls._handle_drift_on_enable(obj) + current_length = _segment_world_length(obj) + props.snap_object_scale_z = obj.scale.z + props.snap_length = current_length + props.length = current_length + props.mesh_dirty = False + props.is_editing = True + + @classmethod + def _finish_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> tuple[bool, bool]: + """Returns ``(resolved, committed)``: ``resolved`` is False when the + target is no longer this MEP segment type; ``committed`` is True when + a length change was written through ``set_depth``.""" + resolved = cls._resolve(obj) + if resolved is None: + return False, False + _element, props = resolved + committed = False + if props.length != props.snap_length: + # set_depth rebuilds the representation 1:1 with the new length, so + # reset scale to 1.0 or any preview stretch would double-apply. + DumbProfileJoiner().set_depth(obj, props.length) + _restore_segment_scale_to(obj, 1.0) + props.mesh_dirty = False + committed = True + else: + _restore_segment_mesh_if_dirty(props, obj) + cls._handle_drift_on_finish(obj) + props.is_editing = False + return True, committed + + @classmethod + def _cancel_one(cls, obj: bpy.types.Object) -> None: + resolved = cls._resolve(obj) + if resolved is None: + return + element, props = resolved + # Disable editing first so the length-restore below doesn't fire one + # more preview pass. + props.is_editing = False + props.length = props.snap_length + _restore_segment_mesh_if_dirty(props, obj) + cls._handle_drift_on_cancel(obj, element) + + def _enable_targets(self, context: bpy.types.Context) -> set[str]: + obj = context.active_object + if obj is None: + return {"CANCELLED"} + # Resolve pre-flight to map a non-matching active object to CANCELLED + # rather than the silent no-op the per-target classmethod would produce. + resolved = self._resolve(obj) + if resolved is None: + return {"CANCELLED"} + self._enable_one(obj) + return {"FINISHED"} + + def _finish_targets(self, context: bpy.types.Context) -> set[str]: + obj = context.active_object + if obj is None: + return {"CANCELLED"} + resolved_ok, committed = self._finish_one(obj, context) + if not resolved_ok: + return {"CANCELLED"} + if committed: + # Re-align adjacent fittings + segments to follow the port move; + # failure here doesn't roll back the length commit (primary intent). + try: + bpy.ops.bim.regenerate_distribution_element() + except Exception as e: + self.report({"WARNING"}, f"Length committed but auto-regenerate failed: {e}") + return {"FINISHED"} + + def _cancel_targets(self, context: bpy.types.Context) -> set[str]: + obj = context.active_object + if obj is None: + return {"CANCELLED"} + self._cancel_one(obj) + return {"FINISHED"} + + +class _PipeSegmentEditMixin(_MEPSegmentEditMixin): + @classmethod + def _is_element_type(cls, element): + return tool.Parametric.is_pipe_segment(element) + + @classmethod + def _get_props(cls, obj: bpy.types.Object): + return tool.Model.get_pipe_segment_props(obj) + + +class _DuctSegmentEditMixin(_MEPSegmentEditMixin): + @classmethod + def _is_element_type(cls, element): + return tool.Parametric.is_duct_segment(element) + + @classmethod + def _get_props(cls, obj: bpy.types.Object): + return tool.Model.get_duct_segment_props(obj) + + +EnableEditingPipeSegment, FinishEditingPipeSegment, CancelEditingPipeSegment = tool.Parametric.build_edit_lifecycle( + "pipe_segment", + _PipeSegmentEditMixin, + labels=( + ("Edit Pipe Segment", ""), + ("Apply Pipe Segment Edits", ""), + ("Discard Pipe Segment Edits", ""), + ), + module_name=__name__, +) + +EnableEditingDuctSegment, FinishEditingDuctSegment, CancelEditingDuctSegment = tool.Parametric.build_edit_lifecycle( + "duct_segment", + _DuctSegmentEditMixin, + labels=( + ("Edit Duct Segment", ""), + ("Apply Duct Segment Edits", ""), + ("Discard Duct Segment Edits", ""), + ), + module_name=__name__, +) + + +def _project_cursor_to_segment_local_z(context, *, is_pipe: bool) -> tuple[bpy.types.Object | None, float | None]: + """Validate the active object is an MEP segment of the requested kind, + commit any in-progress parametric edit, and return ``(obj, cursor_local_z)``. + + Returns ``(None, None)`` on precondition failure — callers should treat + that as ``{"CANCELLED"}``.""" + obj = context.active_object + if obj is None: + return None, None + element = tool.Ifc.get_entity(obj) + if element is None: + return None, None + predicate = tool.Parametric.is_pipe_segment if is_pipe else tool.Parametric.is_duct_segment + if not predicate(element): + return None, None + + # Commit any in-progress edit first so the user's drag-state isn't + # silently discarded — cursor-anchored ops must layer on top of an + # in-progress edit, not overwrite it. + props = tool.Model.get_pipe_segment_props(obj) if is_pipe else tool.Model.get_duct_segment_props(obj) + if props.is_editing: + with bpy.context.temp_override(active_object=obj, selected_objects=[obj]): + if is_pipe: + bpy.ops.bim.finish_editing_pipe_segment() + else: + bpy.ops.bim.finish_editing_duct_segment() + + cursor_world = context.scene.cursor.location + cursor_local = obj.matrix_world.inverted() @ cursor_world + return obj, cursor_local.z + + +def _extend_segment_to_cursor(context, *, is_pipe: bool) -> set[str]: + """Extend or trim the nearest endpoint of the segment to the cursor + projection.""" + obj, _ = _project_cursor_to_segment_local_z(context, is_pipe=is_pipe) + if obj is None: + return {"CANCELLED"} + DumbProfileJoiner().join_E(obj, context.scene.cursor.location) + return {"FINISHED"} + + +class ExtendPipeSegmentToCursor(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.extend_pipe_segment_to_cursor" + bl_label = "Extend Pipe Segment to Cursor" + bl_description = ( + "Extend or trim the active pipe segment so its nearest endpoint reaches the 3D cursor's projection " + "on the segment axis" + ) + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context): + return _extend_segment_to_cursor(context, is_pipe=True) + + +class ExtendDuctSegmentToCursor(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.extend_duct_segment_to_cursor" + bl_label = "Extend Duct Segment to Cursor" + bl_description = ( + "Extend or trim the active duct segment so its nearest endpoint reaches the 3D cursor's projection " + "on the segment axis" + ) + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context): + return _extend_segment_to_cursor(context, is_pipe=False) + + +def split_mep_segment(obj: bpy.types.Object, cut_local_z: float) -> bpy.types.Object | None: + """Split an MEP segment at ``cut_local_z`` along its local +Z axis, + producing two connected segments where there was one. + + Snapshots the downstream end-port connection, duplicates the segment via + ``bonsai.core.root.copy_class``, positions the new segment so its start + coincides with the original's new end, calls ``DumbProfileJoiner.set_depth`` + on both halves, then reconnects ports: original-end ↔ new-start, and + if a downstream connection existed: new-end ↔ snapshotted downstream + with the preserved direction. Rejects splits within 0.01m of either + endpoint.""" + from bonsai.tool.system import direction_from_port_pair + + element = tool.Ifc.get_entity(obj) + if element is None or not tool.System.is_mep_element(element): + return None + + start_world, end_world = tool.Model.get_flow_segment_axis(obj) + original_length = (end_world - start_world).length + if cut_local_z < 0.01 or cut_local_z > original_length - 0.01: + return None + + segment_data = MEPGenerator.get_segment_data(element) + end_port = segment_data.get("end_port") + downstream_port = None + downstream_direction = "NOTDEFINED" + if end_port is not None: + downstream_port = tool.System.get_connected_port(end_port) + if downstream_port is not None: + downstream_direction = direction_from_port_pair(end_port, downstream_port) + + new_obj = obj.copy() + if obj.data is not None: + new_obj.data = obj.data.copy() + for collection in obj.users_collection: + collection.objects.link(new_obj) + new_element = bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=new_obj) + if new_element is None: + bpy.data.objects.remove(new_obj, do_unlink=True) + return None + + local_z = obj.matrix_world.to_3x3() @ Vector((0.0, 0.0, 1.0)) + local_z.normalize() + new_obj.matrix_world.translation = obj.matrix_world.translation + local_z * cut_local_z + + joiner = DumbProfileJoiner() + joiner.set_depth(obj, cut_local_z) + joiner.set_depth(new_obj, original_length - cut_local_z) + + seg1_data = MEPGenerator.get_segment_data(element) + seg2_data = MEPGenerator.get_segment_data(new_element) + seg1_end = seg1_data.get("end_port") + seg2_start = seg2_data.get("start_port") + seg2_end = seg2_data.get("end_port") + + if seg1_end is not None and seg2_start is not None: + try: + tool.Ifc.run( + "system.connect_port", + port1=seg1_end, + port2=seg2_start, + direction="NOTDEFINED", + ) + except Exception as e: + print(f"Bonsai: split_mep_segment failed to connect halves at cut: {e}") + + if downstream_port is not None and seg2_end is not None: + try: + tool.Ifc.run( + "system.connect_port", + port1=seg2_end, + port2=downstream_port, + direction=downstream_direction, + ) + except Exception as e: + print(f"Bonsai: split_mep_segment failed to restore downstream connection: {e}") + + return new_obj + + +def _split_segment_at_cursor(operator, context, *, is_pipe: bool) -> set[str]: + """Split the active MEP segment at the cursor's projection on its axis.""" + obj, cursor_local_z = _project_cursor_to_segment_local_z(context, is_pipe=is_pipe) + if obj is None or cursor_local_z is None: + return {"CANCELLED"} + new_obj = split_mep_segment(obj, cursor_local_z) + if new_obj is None: + operator.report( + {"WARNING"}, + "Split cancelled — cursor projection must lie between segment endpoints (>=0.01 m from each).", + ) + return {"CANCELLED"} + return {"FINISHED"} + + +class SplitPipeSegmentAtCursor(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.split_pipe_segment_at_cursor" + bl_label = "Split Pipe Segment at Cursor" + bl_description = ( + "Split the active pipe segment at the 3D cursor's projection on the segment axis, " + "producing two connected segments" + ) + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context): + return _split_segment_at_cursor(self, context, is_pipe=True) + + +class SplitDuctSegmentAtCursor(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.split_duct_segment_at_cursor" + bl_label = "Split Duct Segment at Cursor" + bl_description = ( + "Split the active duct segment at the 3D cursor's projection on the segment axis, " + "producing two connected segments" + ) + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context): + return _split_segment_at_cursor(self, context, is_pipe=False) + + +class _MEPSegmentEditionMixin: + """Shared element-specific scaffolding for the two MEP-segment gizmo + groups: an extend-to-cursor icon at the cursor's projection on the + segment axis plus a split icon stacked above it. Cursor-anchored, always + visible when the parametric gizmo group polls.""" + + _extend_operator: str = "" + _split_operator: str = "" + + CURSOR_STACK_OFFSET: ClassVar[float] = 0.4 + + def setup_element_specific_gizmos(self, context): + default_color, highlight_color = self.get_decoration_colors() + self.extend_gizmo = self._setup_icon_gizmo( + "VIEW3D_GT_extend", + default_color, + self._extend_operator, + highlight_color, + ) + warning_color = gizmo.get_warning_color_from_prefs(tool.Blender.get_addon_preferences()) + self.split_gizmo = self._setup_icon_gizmo( + "VIEW3D_GT_split", + default_color, + self._split_operator, + warning_color, + ) + if context.region is not None: + type(self)._active_instances[context.region.as_pointer()] = weakref.ref(self) + + def _refresh_element_specific(self, context, mw, props): + if not hasattr(self, "extend_gizmo"): + return + cursor_world = context.scene.cursor.location + cursor_local = mw.inverted() @ cursor_world + projected_local = Vector((0.0, 0.0, cursor_local.z)) + projected_world = mw @ projected_local + billboard_rot = self._frame_billboard_rot or gizmo.get_billboard_rotation(context) + # Segment extrusion axis in world space — local +Z of the active + # object. The extend icon orients its +X arrow along this so the + # arrow visually runs along the pipe / duct rather than horizontally. + segment_axis_world = (mw.to_3x3() @ Vector((0.0, 0.0, 1.0))).normalized() + + gz = self.extend_gizmo + gz.hide = self.is_gizmo_hidden_by_modal(gz) + gz.matrix_basis = gizmo.billboarded_along_axis(projected_world, billboard_rot, segment_axis_world) + # Flip so the arrow points away from the current segment end (the + # direction the extend would grow). Comparing cursor projection + # against current_length picks the right end regardless of viewport + # orientation. + obj = context.active_object + current_length = max((c[2] for c in obj.bound_box), default=0.0) if obj is not None else 0.0 + if cursor_local.z < current_length: + gz.matrix_basis = gz.matrix_basis @ gizmo.EXTEND_FLIP_MIRROR_X + + if hasattr(self, "split_gizmo"): + split_gz = self.split_gizmo + if obj is None or not obj.bound_box: + split_gz.hide = True + else: + # Endpoint-cut threshold matches split_mep_segment's rejection + # window so the icon never offers an invalid affordance. + in_range = 0.01 < cursor_local.z < (current_length - 0.01) + if not in_range or self.is_gizmo_hidden_by_modal(split_gz): + split_gz.hide = True + else: + split_gz.hide = False + # Stack the split icon perpendicular to the segment axis + # in screen space so it doesn't overlap the rotated + # extend arrow. + camera_forward = billboard_rot @ Vector((0.0, 0.0, 1.0)) + perp_axis = camera_forward.cross(segment_axis_world) + if perp_axis.length < 1e-4: + perp_axis = billboard_rot @ Vector((0.0, 1.0, 0.0)) + else: + perp_axis.normalize() + split_gz.matrix_basis = gizmo.billboarded_at( + projected_world + perp_axis * self.CURSOR_STACK_OFFSET, billboard_rot + ) + + +# Dimension config shared between pipe and duct segments. ``matrix_position`` +# routing through ``compose_gizmo_matrix`` rotates the +X line to ``axis`` so +# the dimension renders along the segment's extrusion direction. +_MEP_SEGMENT_LENGTH_DIMENSION = DimensionGizmoConfig( + attr_name="length", + axis=(0, 0, 1), + matrix_position=lambda _props: Vector((0.0, 0.0, 0.0)), + min_value=0.01, + show_start_arrow=True, + show_end_arrow=True, +) + + +class GizmoPipeSegmentEdition(bpy.types.GizmoGroup, _MEPSegmentEditionMixin, gizmo.BaseParametricGizmoGroup): + """Parametric-edit gizmo for IfcPipeSegment.""" + + bl_idname = "OBJECT_GGT_bim_pipe_segment_edition" + bl_label = "Pipe Segment Editing Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + enable_editing_operator = "bim.enable_editing_pipe_segment" + finish_editing_operator = "bim.finish_editing_pipe_segment" + cancel_editing_operator = "bim.cancel_editing_pipe_segment" + cycle_type_operator = "" + props_getter = tool.Model.get_pipe_segment_props + gizmo_pref_name = "pipe_segment" + _extend_operator = "bim.extend_pipe_segment_to_cursor" + _split_operator = "bim.split_pipe_segment_at_cursor" + + dimension_gizmo_props = [_MEP_SEGMENT_LENGTH_DIMENSION] + + _active_instances: ClassVar["dict[int, weakref.ReferenceType[GizmoPipeSegmentEdition]]"] = {} + + @classmethod + def is_element_type(cls, element): + return tool.Parametric.is_pipe_segment(element) and tool.System.has_parametric_body(element) + + +class GizmoDuctSegmentEdition(bpy.types.GizmoGroup, _MEPSegmentEditionMixin, gizmo.BaseParametricGizmoGroup): + """Parametric-edit gizmo for IfcDuctSegment.""" + + bl_idname = "OBJECT_GGT_bim_duct_segment_edition" + bl_label = "Duct Segment Editing Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + enable_editing_operator = "bim.enable_editing_duct_segment" + finish_editing_operator = "bim.finish_editing_duct_segment" + cancel_editing_operator = "bim.cancel_editing_duct_segment" + cycle_type_operator = "" + props_getter = tool.Model.get_duct_segment_props + gizmo_pref_name = "duct_segment" + _extend_operator = "bim.extend_duct_segment_to_cursor" + _split_operator = "bim.split_duct_segment_at_cursor" + + dimension_gizmo_props = [_MEP_SEGMENT_LENGTH_DIMENSION] + + _active_instances: ClassVar["dict[int, weakref.ReferenceType[GizmoDuctSegmentEdition]]"] = {} + + @classmethod + def is_element_type(cls, element): + return tool.Parametric.is_duct_segment(element) and tool.System.has_parametric_body(element) + + +# --- GizmoMEPActions group + visibility helpers ---------------------------- + + +def _selection_size() -> int: + return len(tool.Blender.get_selected_objects()) + + +def _active_is_flow_segment(obj: bpy.types.Object) -> bool: + element = tool.Ifc.get_entity(obj) + if element is None or not element.is_a("IfcFlowSegment"): + return False + return tool.System.has_parametric_body(element) + + +def _active_mep_has_connected_neighbor(obj: bpy.types.Object) -> bool: + """True iff the active MEP element has at least one port connected to + another element. Hides the path-select icon when clicking would yield + the same single-member selection.""" + element = tool.Ifc.get_entity(obj) + if element is None or not tool.System.is_mep_element(element): + return False + for port in tool.System.get_ports(element): + if tool.System.get_connected_port(port) is not None: + return True + return False + + +def _active_is_bend_fitting(obj: bpy.types.Object) -> bool: + element = tool.Ifc.get_entity(obj) + if not _is_bend_fitting(element): + return False + return tool.System.has_parametric_body(element) + + +class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup): + """Icon-action gizmos for the MEP one-shot operators. + + Most icons sit in a horizontal row above the active object's bbox top. + Lock icons are anchored at the segment's start / end ports and rendered + at half scale as secondary affordances. Visibility predicates gate each + icon on selection cardinality and IFC class; ``position_gizmos`` resolves + the open-vs-closed-vs-unjoin three-state at each port from + ``port_connection_state``.""" + + bl_idname = "OBJECT_GGT_bim_mep_actions" + bl_label = "MEP Actions Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + ENDPOINT_CONFIGS: ClassVar[dict[str, str]] = { + "lock_start_open": "START", + "lock_start_closed": "START", + "lock_end_open": "END", + "lock_end_closed": "END", + "unjoin_start": "START", + "unjoin_end": "END", + } # fmt: skip + BEND_ANCHOR_CONFIGS: ClassVar[set[str]] = {"join", "unjoin_pair"} + UNJOIN_CONFIGS: ClassVar[set[str]] = {"unjoin_start", "unjoin_end", "unjoin_pair"} + ENDPOINT_SCALE_RATIO: ClassVar[float] = 0.5 + + LOCK_ICON_CONFIGS: ClassVar[dict[str, tuple[str, str]]] = { + "lock_start_open": ("VIEW3D_GT_lock_open", "START"), + "lock_start_closed": ("VIEW3D_GT_lock_closed", "START"), + "lock_end_open": ("VIEW3D_GT_lock_open", "END"), + "lock_end_closed": ("VIEW3D_GT_lock_closed", "END"), + } # fmt: skip + + action_configs = [ + IconActionConfig( + name="join", + icon="VIEW3D_GT_merge", + operator="bim.mep_join_segments", + visibility_condition=lambda _active: _n_mep_selected(2), + ), + IconActionConfig( + name="select_path", + icon="VIEW3D_GT_array_all", + operator="bim.select_mep_path_members", + visibility_condition=lambda obj: _selection_size() == 1 and _active_mep_has_connected_neighbor(obj), + ), + IconActionConfig( + name="re_edit_bend", + icon="VIEW3D_GT_pen", + operator="bim.enable_bend_preview_from_bend", + visibility_condition=lambda obj: _selection_size() == 1 and _active_is_bend_fitting(obj), + ), + IconActionConfig( + name="lock_start_open", + icon="VIEW3D_GT_lock_open", + operator="bim.mep_add_obstruction", + visibility_condition=lambda obj: _selection_size() == 1 and _active_is_flow_segment(obj), + ), + IconActionConfig( + name="lock_start_closed", + icon="VIEW3D_GT_lock_closed", + operator="bim.mep_remove_terminal_fitting", + visibility_condition=lambda obj: _selection_size() == 1 and _active_is_flow_segment(obj), + ), + IconActionConfig( + name="lock_end_open", + icon="VIEW3D_GT_lock_open", + operator="bim.mep_add_obstruction", + visibility_condition=lambda obj: _selection_size() == 1 and _active_is_flow_segment(obj), + ), + IconActionConfig( + name="lock_end_closed", + icon="VIEW3D_GT_lock_closed", + operator="bim.mep_remove_terminal_fitting", + visibility_condition=lambda obj: _selection_size() == 1 and _active_is_flow_segment(obj), + ), + IconActionConfig( + name="unjoin_start", + icon="VIEW3D_GT_unjoin", + operator="bim.mep_unjoin_at_port", + visibility_condition=lambda obj: _selection_size() == 1 and _active_is_flow_segment(obj), + ), + IconActionConfig( + name="unjoin_end", + icon="VIEW3D_GT_unjoin", + operator="bim.mep_unjoin_at_port", + visibility_condition=lambda obj: _selection_size() == 1 and _active_is_flow_segment(obj), + ), + IconActionConfig( + name="unjoin_pair", + icon="VIEW3D_GT_unjoin", + operator="bim.mep_unjoin_pair", + visibility_condition=lambda _active: _n_mep_selected(2), + ), + ] + + @classmethod + def is_eligible_object(cls, obj: bpy.types.Object) -> bool: + # Bend preview takes over the viewport for a focused edit flow — + # hide the whole action group while it's active so the validate / + # cancel buttons don't compete with these icons. + scene = bpy.context.scene + preview = getattr(scene, "BIMPreviewProperties", None) if scene else None + bend_props = preview.bend if preview is not None else None + if bend_props is not None and bend_props.is_active: + return False + element = tool.Ifc.get_entity(obj) + if element is None or not tool.System.is_mep_element(element): + return False + return tool.System.has_parametric_body(element) + + def setup(self, context: bpy.types.Context) -> None: + super().setup(context) + self._wire_anchored_icon_targets(self) + + @classmethod + def _wire_anchored_icon_targets(cls, group) -> None: + """Pre-fill ``position`` (and ``mode`` for open-lock) on each anchored + icon so a click dispatches to the right port without a per-frame + property write; apply the warning-red hover colour to destructive + icons. Takes any object with ``action__gizmo`` attributes so + tests can exercise the wiring without instantiating the GizmoGroup.""" + for config_name, (_icon, position_arg) in cls.LOCK_ICON_CONFIGS.items(): + gz = getattr(group, f"action_{config_name}_gizmo", None) + if gz is None: + continue + is_open = config_name.endswith("_open") + if is_open: + op_props = gz.target_set_operator("bim.mep_add_obstruction") + op_props.position = position_arg + op_props.mode = "ADD" + else: + op_props = gz.target_set_operator("bim.mep_remove_terminal_fitting") + op_props.position = position_arg + + for config_name, position_arg in (("unjoin_start", "START"), ("unjoin_end", "END")): + gz = getattr(group, f"action_{config_name}_gizmo", None) + if gz is None: + continue + op_props = gz.target_set_operator("bim.mep_unjoin_at_port") + op_props.position = position_arg + + warning_color = gizmo.get_warning_color_from_prefs(tool.Blender.get_addon_preferences()) + for config_name in cls.UNJOIN_CONFIGS: + gz = getattr(group, f"action_{config_name}_gizmo", None) + if gz is None: + continue + gz.color_highlight = warning_color + + def position_gizmos(self, context: bpy.types.Context) -> None: + """Lay out icons across three regions: row above bbox top, segment + port endpoints (``ENDPOINT_CONFIGS``), and predicted bend / transition + location (``BEND_ANCHOR_CONFIGS``).""" + from bonsai.bim.module.model.decorator import compute_mep_join_location + + obj = context.active_object + if obj is None: + return + billboard_rot = gizmo.get_billboard_rotation(context) + z_top = max((c[2] for c in obj.bound_box), default=0.0) + z_anchor = z_top + self.ICON_ROW_Z_OFFSET + + # Restore last frame's IFC-derived state when the cache key is still + # valid (same active + same selection signature + same IFC generation). + # Camera-dependent state (billboard_rot, matrix_basis) is still rebuilt + # every frame below — only the expensive port/fitting/axis lookups are + # cached. + current_gen = tool.Parametric.get_geom_generation() + selection_sig = tuple(sorted(o.name for o in tool.Blender.get_selected_objects())) + cache_key = (obj.name, selection_sig, current_gen) + if getattr(self, "_mep_state_cache_key", None) == cache_key: + cache = self._mep_state_cache + port_state_at = cache["port_state_at"] + pair_fitting = cache["pair_fitting"] + segment_endpoints = cache["segment_endpoints"] + bend_anchor = cache["bend_anchor"] + bend_anchor_attempted = cache["bend_anchor_attempted"] + else: + segment_endpoints = None + bend_anchor = None + bend_anchor_attempted = False + port_state_at = {} + # pair_fitting tri-state: None = not computed; False = computed, no + # fitting joins the pair; = the joining fitting. + pair_fitting = None + + row_index = 0 + for config in self.action_configs: + gz = getattr(self, f"action_{config.name}_gizmo", None) + if gz is None: + continue + if config.visibility_condition is not None and not config.visibility_condition(obj): + gz.hide = True + continue + gz.hide = False + + scale = self._scale_for_config(config.name) + + endpoint_kind = self.ENDPOINT_CONFIGS.get(config.name) + if endpoint_kind is not None: + if endpoint_kind not in port_state_at: + element = tool.Ifc.get_entity(obj) + port_state_at[endpoint_kind] = ( + port_connection_state(element, endpoint_kind == "START") if element else PORT_FREE + ) + state = port_state_at[endpoint_kind] + if config.name.startswith("unjoin_"): + visible = state == PORT_JOINED + else: + is_closed_icon = config.name.endswith("_closed") + visible = (state == PORT_TERMINAL) if is_closed_icon else (state == PORT_FREE) + if not visible: + gz.hide = True + continue + if segment_endpoints is None: + segment_endpoints = tool.Model.get_flow_segment_axis(obj) + start_world, end_world = segment_endpoints + anchor = start_world if endpoint_kind == "START" else end_world + gz.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot, scale=scale) + elif config.name in self.BEND_ANCHOR_CONFIGS: + if pair_fitting is None: + selected = tool.Blender.get_selected_objects() + if len(selected) == 2: + elements = [tool.Ifc.get_entity(o) for o in selected] + if all(e is not None and e.is_a("IfcFlowSegment") for e in elements): + pair_fitting = find_fitting_between_segments(elements[0], elements[1]) or False + else: + pair_fitting = False + else: + pair_fitting = False + + wants_fitting = config.name == "unjoin_pair" + fitting_present = bool(pair_fitting) + if wants_fitting != fitting_present: + gz.hide = True + continue + + if not bend_anchor_attempted: + bend_anchor = compute_mep_join_location() + bend_anchor_attempted = True + if bend_anchor is None: + gz.hide = True + continue + gz.matrix_basis = gizmo.billboarded_at(bend_anchor, billboard_rot, scale=scale) + else: + local_pos = Vector((row_index * self.ICON_SPACING_X, 0.0, z_anchor)) + world_pos = obj.matrix_world @ local_pos + gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot, scale=scale) + row_index += 1 + + self._mep_state_cache_key = cache_key + self._mep_state_cache = { + "port_state_at": port_state_at, + "pair_fitting": pair_fitting, + "segment_endpoints": segment_endpoints, + "bend_anchor": bend_anchor, + "bend_anchor_attempted": bend_anchor_attempted, + } + + def _scale_for_config(self, name: str) -> float: + if name in self.UNJOIN_CONFIGS: + return gizmo.DEFAULT_BILLBOARD_SCALE + if name in self.ENDPOINT_CONFIGS: + return self.ICON_SCALE * self.ENDPOINT_SCALE_RATIO + return self.ICON_SCALE diff --git a/src/bonsai/bonsai/bim/module/model/mep_bend_preview.py b/src/bonsai/bonsai/bim/module/model/mep_bend_preview.py new file mode 100644 index 0000000000..57058e767b --- /dev/null +++ b/src/bonsai/bonsai/bim/module/model/mep_bend_preview.py @@ -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 . +# +# 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) diff --git a/src/bonsai/bonsai/bim/module/model/opening.py b/src/bonsai/bonsai/bim/module/model/opening.py index 1c157afe4e..4a16bee5af 100644 --- a/src/bonsai/bonsai/bim/module/model/opening.py +++ b/src/bonsai/bonsai/bim/module/model/opening.py @@ -15,6 +15,8 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# This file was modified with the assistance of an AI coding tool. from collections.abc import Sequence from math import radians @@ -41,8 +43,202 @@ 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) + class FilledOpeningGenerator: def generate( @@ -50,9 +246,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 +276,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() @@ -558,6 +760,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" @@ -941,7 +1166,6 @@ class SelectBoolean(Operator): return {"FINISHED"} -# TODO: merge with ProfileDecorator? class DecorationsHandler: installed = None @@ -951,6 +1175,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 +1184,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 +1290,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 +1328,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 +1353,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")) diff --git a/src/bonsai/bonsai/bim/module/model/polyline.py b/src/bonsai/bonsai/bim/module/model/polyline.py index be491e756b..393054d9ac 100644 --- a/src/bonsai/bonsai/bim/module/model/polyline.py +++ b/src/bonsai/bonsai/bim/module/model/polyline.py @@ -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"]}, diff --git a/src/bonsai/bonsai/bim/module/model/preview_base.py b/src/bonsai/bonsai/bim/module/model/preview_base.py new file mode 100644 index 0000000000..f15df9c1a9 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/model/preview_base.py @@ -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 . +# +# 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: + + EnablePreview — validates a selection, populates draft state on + ``Scene.BIMPreviewProperties.``, flips ``is_active``. + GizmoPreview — polls on ``is_active``, surfaces tunable widgets + + validate/cancel icons. + PreviewDecorator — GPU lines drawn while ``is_active`` is True. + FinishPreview — direct ``bpy.ops.bim.(...)`` call with kwargs + read off the draft state, then clears it. + CancelPreview — 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 ``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.(**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 diff --git a/src/bonsai/bonsai/bim/module/model/profile.py b/src/bonsai/bonsai/bim/module/model/profile.py index e5c0991e8e..d6fcb4c6fd 100644 --- a/src/bonsai/bonsai/bim/module/model/profile.py +++ b/src/bonsai/bonsai/bim/module/model/profile.py @@ -1157,7 +1157,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): diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py index ff6ea96130..b9709c067a 100644 --- a/src/bonsai/bonsai/bim/module/model/prop.py +++ b/src/bonsai/bonsai/bim/module/model/prop.py @@ -15,6 +15,8 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# This file was modified with the assistance of an AI coding tool. import math from collections.abc import Callable @@ -101,8 +103,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: @@ -193,6 +199,32 @@ def update_stair(self: "BIMStairProperties", context: bpy.types.Context) -> None _get_updater("stair", "regenerate_stair_mesh")(obj) +def update_wall(self: "BIMWallProperties", context: bpy.types.Context) -> None: + """Regenerate wall mesh preview when property changes. Does NOT touch IFC.""" + obj = context.active_object + if obj and self.is_editing: + _get_updater("wall", "regenerate_wall_mesh_from_props")(obj) + + +def update_wall_offset_baseline(self: "BIMWallProperties", context: bpy.types.Context) -> None: + """Recompute the preview-only ``offset`` when the draft baseline cycles. Does not touch IFC. + + ``offset`` itself has no ``update`` callback on purpose — adding one would make + every baseline cycle rebuild the bmesh twice (once via offset's callback, once + explicitly below).""" + obj = context.active_object + if not (obj and self.is_editing): + return + t = self.thickness + if self.desired_offset_baseline == "CENTER": + self.offset = -t / 2 + elif self.desired_offset_baseline == "INTERIOR": + self.offset = -t + else: # EXTERIOR + self.offset = 0.0 + _get_updater("wall", "regenerate_wall_mesh_from_props")(obj) + + def update_railing(self: "BIMRailingProperties", context: bpy.types.Context) -> None: """Regenerate railing mesh when property changes.""" if self.is_editing: @@ -210,6 +242,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( @@ -369,8 +415,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 +437,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 +454,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 +1693,118 @@ class BIMRoofProperties(PropertyGroup): setattr(target_props, prop_name, prop_value) +class BIMWallProperties(PropertyGroup): + """Transient draft state for parametric wall gizmo editing. + + Populated from IFC on `bim.enable_editing_wall`, mutated by gizmo drags during edit + (preview only — no IFC writes), and either committed by `bim.finish_editing_wall` + or discarded by `bim.cancel_editing_wall`. + + The `snap_*` fields are the values captured on enable; `finish_editing_wall` compares + current vs snap to skip unchanged params and guarantee a no-op session leaves the + IFC file byte-identical. + """ + + is_editing: bpy.props.BoolProperty( + default=False, + description="True while wall parametric edit mode is active.", + ) + mesh_dirty: bpy.props.BoolProperty( + default=False, + options={"HIDDEN", "SKIP_SAVE"}, + description=( + "True while the visible mesh is the preview box; cleared once the real " + "IFC-derived geometry is restored (on commit or cancel)." + ), + ) + length: bpy.props.FloatProperty( + name="Length", + default=1.0, + min=0.01, + subtype="DISTANCE", + update=update_wall, + description="Wall length along its reference axis (preview value; committed on finish).", + ) + height: bpy.props.FloatProperty( + name="Height", + default=3.0, + min=0.01, + subtype="DISTANCE", + update=update_wall, + description="Wall vertical height (preview value; committed on finish).", + ) + x_angle: bpy.props.FloatProperty( + name="Slope (X Angle)", + default=0.0, + soft_min=-math.pi / 3, + soft_max=math.pi / 3, + subtype="ANGLE", + update=update_wall, + description="Slope angle: tilt of the wall's top face along +Y (preview value; committed on finish).", + ) + thickness: bpy.props.FloatProperty( + name="Thickness", + default=0.2, + min=0.001, + subtype="DISTANCE", + description="Wall thickness captured from IFC at edit-enable; not gizmo-bound.", + ) + offset: bpy.props.FloatProperty( + name="Offset", + default=0.0, + subtype="DISTANCE", + description="Layer-set offset captured from IFC at edit-enable; driven by desired_offset_baseline.", + ) + desired_offset_baseline: bpy.props.EnumProperty( + items=[ + ("EXTERIOR", "Exterior", "Reference axis at the exterior face"), + ("CENTER", "Center", "Reference axis at the wall centreline"), + ("INTERIOR", "Interior", "Reference axis at the interior face"), + ], + name="Desired Offset Baseline", + default="CENTER", + update=update_wall_offset_baseline, + description="Which face of the wall the reference axis aligns to (preview value; committed on finish).", + ) + anchor_x: bpy.props.FloatProperty( + default=0.0, + subtype="DISTANCE", + description="Local-X of the wall's axis polyline start, so the preview box lands where the IFC mesh does.", + ) + + snap_length: bpy.props.FloatProperty(description="Snapshot of length at edit-enable; commit skips no-op writes.") + snap_height: bpy.props.FloatProperty(description="Snapshot of height at edit-enable; commit skips no-op writes.") + snap_thickness: bpy.props.FloatProperty( + description="Snapshot of thickness at edit-enable; commit skips no-op writes." + ) + snap_offset: bpy.props.FloatProperty(description="Snapshot of offset at edit-enable; commit skips no-op writes.") + snap_x_angle: bpy.props.FloatProperty( + subtype="ANGLE", + description="Snapshot of x_angle at edit-enable; commit skips no-op writes.", + ) + snap_offset_baseline: bpy.props.StringProperty( + default="", + description="Snapshot of desired_offset_baseline at edit-enable; commit skips no-op writes.", + ) + + if TYPE_CHECKING: + is_editing: bool + mesh_dirty: bool + length: float + height: float + x_angle: float + thickness: float + offset: float + desired_offset_baseline: Literal["EXTERIOR", "CENTER", "INTERIOR"] + anchor_x: float + snap_length: float + snap_height: float + snap_thickness: float + snap_offset: float + snap_x_angle: float + snap_offset_baseline: str + + class SnapMousePoint(PropertyGroup): x: bpy.props.FloatProperty(name="X") y: bpy.props.FloatProperty(name="Y") @@ -1762,3 +1936,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 diff --git a/src/bonsai/bonsai/bim/module/model/railing.py b/src/bonsai/bonsai/bim/module/model/railing.py index 7ca66d8dbc..6f3697d51d 100644 --- a/src/bonsai/bonsai/bim/module/model/railing.py +++ b/src/bonsai/bonsai/bim/module/model/railing.py @@ -34,6 +34,7 @@ import bonsai.core.root import bonsai.tool as tool from bonsai.bim.module.model.data import RailingData, refresh from bonsai.bim.module.model.decorator import ProfileDecorator +from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin # reference: # https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRailing.htm @@ -92,7 +93,6 @@ def update_railing_modifier_ifc_data(context: bpy.types.Context) -> None: si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) representation_data = { - "railing_type": props.railing_type, "context": body, "railing_path": railing_path, "use_manual_supports": props.use_manual_supports, @@ -406,66 +406,65 @@ class CopyRailingParameters(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} -class EnableEditingRailing(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.enable_editing_railing" - bl_label = "Enable Editing Railing" - bl_options = {"REGISTER"} +class _RailingEditMixin(PathPreservingEditMixin): + """Type-specific hooks for railing parametric-edit operators. Single-object + (active_object). ``path_data`` is preserved through the edit; the separate + ``Enable/Finish/CancelEditingRailingPath`` operators handle path editing.""" - def _execute(self, context): - obj = context.active_object - assert obj - props = tool.Model.get_railing_props(obj) - data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"] + pset_name = "BBIM_Railing" + + @classmethod + def _is_element_type(cls, element): + return tool.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"] - props = tool.Model.get_railing_props(obj) - - # restore previous settings since editing was canceled - props.set_props_kwargs_from_ifc_data(data) + @classmethod + def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: update_railing_modifier_bmesh(context) - props.is_editing = False - return {"FINISHED"} - -class FinishEditingRailing(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.finish_editing_railing" - bl_label = "Finish Editing Railing" - bl_options = {"REGISTER"} +class EnableEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.enable_editing_railing" + bl_label = "Enable Editing Railing" + bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - obj = context.active_object - assert obj - element = tool.Ifc.get_entity(obj) - assert element - props = tool.Model.get_railing_props(obj) + return self._enable_targets(context) - pset_data = tool.Model.get_modeling_bbim_pset_data(bpy.context.active_object, "BBIM_Railing") - path_data = pset_data["data_dict"]["path_data"] - railing_data = props.get_general_kwargs(convert_to_project_units=True) - railing_data["path_data"] = path_data - props.is_editing = False +class CancelEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.cancel_editing_railing" + bl_label = "Cancel Editing Railing" + bl_options = {"REGISTER", "UNDO"} - update_bbim_railing_pset(element, railing_data) - update_railing_modifier_ifc_data(context) - return {"FINISHED"} + def _execute(self, context): + return self._cancel_targets(context) + + +class FinishEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.finish_editing_railing" + bl_label = "Finish Editing Railing" + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context): + return self._finish_targets(context) class FlipRailingPathOrder(bpy.types.Operator, tool.Ifc.Operator): diff --git a/src/bonsai/bonsai/bim/module/model/roof.py b/src/bonsai/bonsai/bim/module/model/roof.py index e1f7903299..823f95a8e1 100644 --- a/src/bonsai/bonsai/bim/module/model/roof.py +++ b/src/bonsai/bonsai/bim/module/model/roof.py @@ -17,8 +17,8 @@ # along with Bonsai. If not, see . 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): diff --git a/src/bonsai/bonsai/bim/module/model/stair.py b/src/bonsai/bonsai/bim/module/model/stair.py index 87152c645b..70295d6e72 100644 --- a/src/bonsai/bonsai/bim/module/model/stair.py +++ b/src/bonsai/bonsai/bim/module/model/stair.py @@ -15,6 +15,8 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# 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, + ) diff --git a/src/bonsai/bonsai/bim/module/model/ui.py b/src/bonsai/bonsai/bim/module/model/ui.py index eef71ed433..e2b73dfe3d 100644 --- a/src/bonsai/bonsai/bim/module/model/ui.py +++ b/src/bonsai/bonsai/bim/module/model/ui.py @@ -24,6 +24,7 @@ from typing import TYPE_CHECKING, Any import bpy from bpy.types import Panel +import ifcopenshell.util.unit import bonsai.bim import bonsai.tool as tool from bonsai.bim.helper import prop_with_search @@ -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" diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 4d0c9b3de7..dd9d21697c 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -16,13 +16,18 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . # +# This file was modified with the assistance of an AI coding tool. +# # pyright: reportUnnecessaryTypeIgnoreComment=error import copy import math +import weakref +from collections.abc import Iterable from math import atan2, cos, degrees, pi, sin -from typing import TYPE_CHECKING, Any, Literal, Union, get_args +from typing import TYPE_CHECKING, Any, ClassVar, Literal, Optional, Union, get_args +import bmesh import bpy import ifcopenshell import ifcopenshell.api.feature @@ -31,9 +36,11 @@ import ifcopenshell.api.material import ifcopenshell.api.pset import ifcopenshell.api.root import ifcopenshell.api.type +import ifcopenshell.geom import ifcopenshell.util.element import ifcopenshell.util.placement import ifcopenshell.util.representation +import ifcopenshell.util.shape import ifcopenshell.util.shape_builder import ifcopenshell.util.type import ifcopenshell.util.unit @@ -44,13 +51,221 @@ from mathutils import Matrix, Vector import bonsai.core.geometry import bonsai.core.model as core import bonsai.core.root +import bonsai.core.spatial import bonsai.tool as tool from bonsai.bim.ifc import IfcStore -from bonsai.bim.module.model.decorator import PolylineDecorator, ProductDecorator +from bonsai.bim.module.drawing import gizmos as gizmo +from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig, IconSlot +from bonsai.bim.module.model import preview_base +from bonsai.bim.module.model.decorator import ( + _BBOX_HIGHLIGHT_LINE_ALPHA, + _BBOX_HIGHLIGHT_LINE_WIDTH, + PolylineDecorator, + ProductDecorator, + _fill_quads_alpha, + bbox_world_edges, + draw_polyline_segments, +) from bonsai.bim.module.model.polyline import PolylineOperator +if TYPE_CHECKING: + from bonsai.bim.module.model.prop import BIMWallProperties -class UnjoinWalls(bpy.types.Operator, tool.Ifc.Operator): + +_FILLET_DEFAULT_RADIUS_M = 0.5 # Fallback when the leg-fraction heuristic cannot resolve a value. +_FILLET_DEFAULT_LEG_FRACTION = 0.25 # Quarter of the shorter available leg — visible without overrunning either wall. +_FILLET_MIN_RADIUS_M = 0.001 # Lower bound — anything smaller renders as a single pixel at common viewport scales. + +_ARRAY_CHILD_POLL_MESSAGE = "Selection includes an array child; operate on the array parent instead." + + +def _poll_reject_array_children(operator_cls) -> bool: + """Shared operator-poll guard: set the array-child poll message on + ``operator_cls`` and return ``True`` when the selection includes a Bonsai + array child, so the caller can early-return ``False`` from its ``poll``. + + Topology mutations against an array child are wiped by the next + ``regenerate_array`` and would orphan the child's GUID in the parent's + ``BBIM_Array.Data``. Gizmo groups have their own filter via + ``_wall_topology_gizmo_poll_gate``; this helper exists so operator + classes share the same rejection in one line.""" + if tool.Blender.Modifier.any_selected_is_array_child(): + operator_cls.poll_message_set(_ARRAY_CHILD_POLL_MESSAGE) + return True + return False + + +def _wall_gizmo_poll_gate(context: bpy.types.Context) -> bool: + """Common pre-flight gate every wall gizmo group's ``poll`` runs first: + viewport gizmos are enabled AND no preview is active. Centralises the + two checks every wall gizmo group otherwise duplicates inline; returning + ``False`` here short-circuits the caller's poll before any per-feature + selection inspection runs.""" + if not tool.Blender.are_viewport_gizmos_enabled(): + return False + if preview_base.any_preview_active(context): + return False + return True + + +def _wall_topology_gizmo_poll_gate(context: bpy.types.Context) -> bool: + """Tighter gate for wall topology gizmos (merge / join / extend / unjoin + / fillet): base ``_wall_gizmo_poll_gate`` plus an array-child filter. + Array children are managed replicas — any topology mutation is wiped by + the next ``regenerate_array``, and ``merge`` would orphan a GUID listed + in the parent's ``BBIM_Array.Data``. Host-opening gizmos (add / toggle) + deliberately stay on the base gate so openings remain authorable on + children, which the array regen pipeline preserves.""" + if not _wall_gizmo_poll_gate(context): + return False + if tool.Blender.Modifier.any_selected_is_array_child(): + return False + return True + + +def _wall_has_openings(gz_group: bpy.types.GizmoGroup) -> bool: + """``visible_when`` predicate for the toggle_openings idle slot. Returns + True iff the active object's IFC element exposes a non-empty HasOpenings + inverse — keeps the toggle hidden on walls that carry no opening cuts.""" + 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) + + +def regenerate_wall_mesh_from_props(obj: bpy.types.Object) -> None: + """Rebuild ``obj.data`` as a preview box from ``BIMWallProperties`` without touching IFC. + + The preview omits openings, layer materials, and connection joins; those are + resolved on commit by ``recreate_wall`` / ``recalculate_walls``.""" + props = tool.Model.get_wall_props(obj) + length = max(props.length, 0.001) + height = max(props.height, 0.001) + thickness = max(props.thickness, 0.001) + offset = props.offset + x_angle = props.x_angle + x0 = props.anchor_x + x1 = x0 + length + y0 = offset + y1 = offset + thickness + # Slope shifts the top face along +Y by height * tan(x_angle), keeping the bottom fixed. + y_top_shift = core.displacement_from_x_angle(height, x_angle) if x_angle else 0.0 + + bm = bmesh.new() + verts = [ + bm.verts.new((x0, y0, 0.0)), + bm.verts.new((x1, y0, 0.0)), + bm.verts.new((x1, y1, 0.0)), + bm.verts.new((x0, y1, 0.0)), + bm.verts.new((x0, y0 + y_top_shift, height)), + bm.verts.new((x1, y0 + y_top_shift, height)), + bm.verts.new((x1, y1 + y_top_shift, height)), + bm.verts.new((x0, y1 + y_top_shift, height)), + ] + bm.faces.new([verts[0], verts[1], verts[2], verts[3]]) + bm.faces.new([verts[7], verts[6], verts[5], verts[4]]) + bm.faces.new([verts[0], verts[4], verts[5], verts[1]]) + bm.faces.new([verts[3], verts[2], verts[6], verts[7]]) + bm.faces.new([verts[0], verts[3], verts[7], verts[4]]) + bm.faces.new([verts[1], verts[5], verts[6], verts[2]]) + + assert isinstance(obj.data, bpy.types.Mesh) + bmesh.ops.recalc_face_normals(bm, faces=bm.faces) + bm.to_mesh(obj.data) + bm.free() + obj.data.update() + # Mark the mesh as having diverged from the IFC-derived geometry. cancel / + # no-op-finish reads this and calls recreate_wall to restore openings & layers. + tool.Model.get_wall_props(obj).mesh_dirty = True + + +def _restore_wall_mesh_if_dirty(obj: bpy.types.Object) -> None: + """Re-derive the wall mesh from IFC if the bmesh preview replaced the real geometry. + + Idempotent: clears the dirty flag after restoring. Does call into + ``ifcopenshell.api.geometry.regenerate_wall_representation`` (one ifc.run), which is + acceptable here because cancel / no-op-finish are explicit user actions, not per-frame + events. Skipping the call when no drag happened preserves the byte-identical guarantee + for the common enable → ✓ no-drag round-trip.""" + props = tool.Model.get_wall_props(obj) + if not props.mesh_dirty: + return + element = tool.Ifc.get_entity(obj) + if element: + tool.Model.recreate_wall(element, obj) + props.mesh_dirty = False + + +def _read_wall_state_into_props(obj: bpy.types.Object, props: "BIMWallProperties") -> None: + """Populate the draft props from current IFC state. Caller must have validated the + wall via ``tool.Wall.validate_for_parametric_edit`` first — this function assumes the + wall has a LAYER2 usage and an extruded MODEL_VIEW body.""" + geom = tool.Wall.read_geometry(obj) + assert geom + + props.anchor_x = geom["anchor_x"] + props.length = max(0.01, geom["length"]) + props.height = max(0.01, geom["height"]) + props.x_angle = geom["x_angle"] + props.thickness = max(0.001, geom["thickness"]) + props.offset = geom["offset"] + props.desired_offset_baseline = core.baseline_from_offset(props.offset, props.thickness) + + props.snap_length = props.length + props.snap_height = props.height + props.snap_thickness = props.thickness + props.snap_offset = props.offset + props.snap_x_angle = props.x_angle + props.snap_offset_baseline = props.desired_offset_baseline + + +def _maybe_resync_wall_props_from_ifc(obj: "bpy.types.Object | None") -> None: + """Re-prime ``BIMWallProperties`` from current IFC after an IFC mutation, so + non-edit-mode gizmos read post-mutation coordinates. Must be called from an + operator's ``_execute`` — ID writes from ``GizmoGroup.refresh`` raise + ``AttributeError: Writing to ID classes in this context is not allowed``. + No-op during a draft session; the draft is then the source of truth.""" + if obj is None: + return + if tool.Wall.validate_for_parametric_edit(obj) is not None: + return + props = tool.Model.get_wall_props(obj) + if props.is_editing: + return + _read_wall_state_into_props(obj, props) + + +def _resync_walls_after_mutation(objs: Iterable["bpy.types.Object | None"]) -> None: + """Re-prime each wall's draft props after a one-shot IFC mutation. Safe to + call from operator ``_execute``: ID writes are allowed there, unlike gizmo + refresh.""" + for obj in objs: + _maybe_resync_wall_props_from_ifc(obj) + + +class _CommitWallDraftsFirstMixin: + """Operator mixin that flushes any in-progress wall parametric drafts in + the current selection before delegating to the subclass's ``_perform``. + + Subclasses implement ``_perform`` instead of ``_execute``; the IFC + transaction opened by ``tool.Ifc.Operator.execute`` wraps both the + commit and the perform. + + Place this BEFORE ``bpy.types.Operator`` in the bases tuple so the + mixin's ``_execute`` resolves first in the MRO.""" + + def _execute(self, context: bpy.types.Context): + _commit_pending_wall_edits_for_selection(context) + return self._perform(context) + + def _perform(self, context: bpy.types.Context): + raise NotImplementedError("Subclasses of _CommitWallDraftsFirstMixin must implement _perform.") + + +class UnjoinWalls(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.unjoin_walls" bl_label = "Unjoin Walls" bl_description = "Unjoin the selected walls" @@ -61,39 +276,141 @@ class UnjoinWalls(bpy.types.Operator, tool.Ifc.Operator): if not tool.Model.has_selected_ifc_objects(): cls.poll_message_set("No IFC objects selected.") return False + if _poll_reject_array_children(cls): + return False return True - def _execute(self, context): + def _perform(self, context): core.unjoin_walls(tool.Ifc, tool.Blender, tool.Geometry, DumbWallJoiner(), tool.Model) + _resync_walls_after_mutation(tool.Blender.get_selected_objects()) -class ExtendWallsToUnderside(bpy.types.Operator, tool.Ifc.Operator): +class UnjoinWallPathConnection(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator): + """Surgical counterpart to `UnjoinWalls`: disconnect the active wall from one + specific partner wall, leaving the active wall's other connections intact. The + partner is identified by IFC GlobalId — invariant under Blender-object renames, + file save/reload, and the undo stack — set on the operator properties by the + single-wall unjoin gizmo at click time.""" + + bl_idname = "bim.unjoin_wall_path_connection" + bl_label = "Unjoin Wall Connection" + bl_description = "Disconnect the active wall from a single specific partner wall" + bl_options = {"REGISTER", "UNDO"} + + other_wall_guid: bpy.props.StringProperty(name="Other Wall GlobalId") + + @classmethod + def poll(cls, context): + if not tool.Model.has_selected_ifc_objects(): + cls.poll_message_set("No IFC objects selected.") + return False + if _poll_reject_array_children(cls): + return False + return True + + def _perform(self, context): + active = tool.Blender.get_active_object(is_selected=True) + if not active: + self.report({"ERROR"}, "Could not resolve walls for surgical unjoin.") + return + elem_active = tool.Ifc.get_entity(active) + if not elem_active: + self.report({"ERROR"}, "Active object is not bound to an IFC entity.") + return + elem_other = None + if self.other_wall_guid: + try: + elem_other = tool.Ifc.get().by_guid(self.other_wall_guid) + except RuntimeError: + elem_other = None + other = tool.Ifc.get_object(elem_other) if elem_other else None + if not elem_other or not other: + self.report({"ERROR"}, "Could not resolve walls for surgical unjoin.") + return + # Walk the inverse graph for the specific IfcRelConnectsPathElements joining + # these two walls and remove only that one. `disconnect_path`'s + # (relating, related) mode only inspects `relating.ConnectedTo`, so a single + # call misses the rel when it was authored with the opposite orientation. + rels = [ + rel + for rel in getattr(elem_active, "ConnectedTo", []) + if rel.is_a("IfcRelConnectsPathElements") and rel.RelatedElement == elem_other + ] + [ + rel + for rel in getattr(elem_active, "ConnectedFrom", []) + if rel.is_a("IfcRelConnectsPathElements") and rel.RelatingElement == elem_other + ] + for rel in rels: + bonsai.core.geometry.remove_connection(tool.Geometry, connection=rel) + # Recreate body+axis on both walls so the mesh state matches the IFC mutation + # and stale miter cuts are dropped. + tool.Model.recreate_wall(elem_active, active) + tool.Model.recreate_wall(elem_other, other) + _resync_walls_after_mutation([active, other]) + + +class ExtendWallsToUnderside(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.extend_walls_to_underside" bl_label = "Extend Walls To Underside" bl_description = "Extend and clip selected walls at the bottom faces of an object" bl_options = {"REGISTER", "UNDO"} - def _execute(self, context): - slab = None + @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 _perform(self, context): + slabs: list[bpy.types.Object] = [] walls: list[bpy.types.Object] = [] - if (obj := tool.Blender.get_active_object(is_selected=True)) and (element := tool.Ifc.get_entity(obj)): - slab = obj - for obj in tool.Blender.get_selected_objects(include_active=False): - if (element := tool.Ifc.get_entity(obj)) and tool.Model.get_usage_type(element) == "LAYER2": + for obj in tool.Blender.get_selected_objects(): + element = tool.Ifc.get_entity(obj) + if not element: + continue + if tool.Model.get_usage_type(element) == "LAYER2": walls.append(obj) - if slab and walls: - core.extend_wall_to_slab(tool.Ifc, tool.Geometry, tool.Model, slab, walls) + else: + slabs.append(obj) + if slabs and walls: + core.extend_wall_to_slab(tool.Ifc, tool.Geometry, tool.Model, slabs, walls) + _resync_walls_after_mutation(walls) else: - self.report({"ERROR"}, "Please select at least one LAYER2 element and an active element") + self.report({"ERROR"}, "Please select at least one LAYER2 element and at least one other IFC element") -class ExtendWallsToWall(bpy.types.Operator, tool.Ifc.Operator): +class RegenerateWallToUnderside(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.regenerate_wall_to_underside" + bl_label = "Regenerate Wall to Underside" + bl_description = "Re-clip selected walls to their connected underside objects after the slab has moved" + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context): + wall_objs = [ + obj + for obj in tool.Blender.get_selected_objects() + if (element := tool.Ifc.get_entity(obj)) and tool.Model.get_usage_type(element) == "LAYER2" + ] + if wall_objs: + core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, wall_objs) + else: + self.report({"ERROR"}, "Please select at least one LAYER2 element") + + +class ExtendWallsToWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.extend_walls_to_wall" bl_label = "Extend Walls To Wall" bl_description = "Extend and trim selected walls to another wall" bl_options = {"REGISTER", "UNDO"} - def _execute(self, context): + @classmethod + def poll(cls, context): + if _poll_reject_array_children(cls): + return False + return True + + def _perform(self, context): target_obj = None objs = [] if ( @@ -123,6 +440,7 @@ class ExtendWallsToWall(bpy.types.Operator, tool.Ifc.Operator): ) tool.Model.recreate_wall(element, obj) tool.Model.recreate_wall(target_element, target_obj) + _resync_walls_after_mutation([target_obj, *objs]) else: self.report({"ERROR"}, "Please select at least one LAYER2 element and one active LAYER2 element") @@ -148,11 +466,11 @@ class ExtendWallsToPolylinePoint(bpy.types.Operator, PolylineOperator, tool.Ifc. def set_origin(self, context, event, connection="ATSTART"): obj = context.active_object - element = tool.Ifc.get_entity(obj) - layers = tool.Model.get_material_layer_parameters(element) - axis = tool.Model.get_wall_axis(obj, layers) - start = Vector((axis["reference"][0][0], axis["reference"][0][1], obj.location.z)) - end = Vector((axis["reference"][1][0], axis["reference"][1][1], obj.location.z)) + ref = tool.Wall.get_world_reference_line(obj) + if ref is None: + return + start = Vector((ref[0].x, ref[0].y, obj.location.z)) + end = Vector((ref[1].x, ref[1].y, obj.location.z)) direcion = end - start value = end if connection == "ATSTART" else start self.input_ui.set_value("X", value[0]) @@ -305,7 +623,7 @@ class FlipWall(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} -class SplitWall(bpy.types.Operator, tool.Ifc.Operator): +class SplitWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.split_wall" bl_label = "Split Wall" bl_options = {"REGISTER", "UNDO"} @@ -318,16 +636,19 @@ class SplitWall(bpy.types.Operator, tool.Ifc.Operator): if not tool.Model.has_selected_ifc_objects(): cls.poll_message_set("No IFC objects selected.") return False + if _poll_reject_array_children(cls): + return False return True - def _execute(self, context): + def _perform(self, context): selected_objs = tool.Model.get_selected_mesh_objects() for obj in selected_objs: DumbWallJoiner().split(obj, context.scene.cursor.location) + _resync_walls_after_mutation(selected_objs) return {"FINISHED"} -class MergeWall(bpy.types.Operator, tool.Ifc.Operator): +class MergeWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.merge_wall" bl_label = "Merge Wall" bl_description = "Merge selected walls into one object" @@ -345,13 +666,19 @@ class MergeWall(bpy.types.Operator, tool.Ifc.Operator): if len(mesh_objects) != 2: cls.poll_message_set("Please select exactly two mesh IFC objects.") return False + if _poll_reject_array_children(cls): + return False return True - def _execute(self, context): + def _perform(self, context): active_obj = context.active_object assert active_obj selected_objs = tool.Model.get_selected_mesh_objects() - DumbWallJoiner().merge(next(o for o in selected_objs if o != active_obj), active_obj) + # The merge deletes the second argument when the walls are collinear; + # only the first survives, so the resync targets the non-active wall. + surviving_obj = next(o for o in selected_objs if o != active_obj) + DumbWallJoiner().merge(surviving_obj, active_obj) + _maybe_resync_wall_props_from_ifc(surviving_obj) return {"FINISHED"} @@ -457,7 +784,7 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle if tool.Model.get_usage_type(element) == "LAYER2": x, y, z = extrusion.ExtrudedDirection.DirectionRatios - depth = extrusion.Depth / abs(1 / cos(existing_x_angle)) + depth = core.vertical_height_from_extrusion_depth(extrusion.Depth, existing_x_angle) perpendicular_depth = depth * abs(1 / cos(x_angle)) extrusion.ExtrudedDirection.DirectionRatios = (0.0, sin(x_angle), cos(x_angle)) layer2_objs.append(obj) @@ -468,14 +795,20 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): 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 - coord_list = builder.get_polyline_coords(extrusion.SweptArea.OuterCurve) - coord_list = [ - (p[0], p[1] * abs(cos(existing_x_angle))) for p in coord_list - ] # Reset the transformation and returns to the original points with 0 degrees - coord_list = [ - (p[0], p[1] * abs(1 / cos(x_angle))) for p in coord_list - ] # Apply the transformation for the new x_angle - builder.set_polyline_coords(extrusion.SweptArea.OuterCurve, coord_list) + profiles = ( + extrusion.SweptArea.Profiles + if extrusion.SweptArea.is_a("IfcCompositeProfileDef") + else [extrusion.SweptArea] + ) + for profile in profiles: + coord_list = builder.get_polyline_coords(profile.OuterCurve) + coord_list = [ + (p[0], p[1] * abs(cos(existing_x_angle))) for p in coord_list + ] # Reset the transformation and returns to the original points with 0 degrees + coord_list = [ + (p[0], p[1] * abs(1 / cos(x_angle))) for p in coord_list + ] # Apply the transformation for the new x_angle + builder.set_polyline_coords(profile.OuterCurve, coord_list) # The extrusion direction calculated previously default to the positive direction # Here we set the extrusion direction to negative if that's the case @@ -1055,6 +1388,62 @@ class DumbWallPlaner: tool.Model.recalculate_walls([w for w in set(walls) if w]) +def _opening_axis_extent(opening, axis_reference, unit_scale): + """Return ``(min_t, max_t)``: the opening's world-space footprint + projected onto ``axis_reference`` as parametric positions along the + wall axis (``0`` is the start of the axis line, ``1`` is its end). + Used to detect openings whose footprint straddles a cut. + + Computed via ``ifcopenshell.geom.create_shape`` so the result is + correct for any representation type Bonsai may produce — mapped + representations, swept-area solids, breps, boolean clips, etc. — + without needing a Blender object (Bonsai hides openings after + ``bim.add_opening``). Falls back to a degenerate single-point range + at the placement origin only when the geometry kernel cannot build + a shape from the opening.""" + verts = None + shape_matrix: Optional[Matrix] = None + try: + settings = ifcopenshell.geom.settings() + shape = ifcopenshell.geom.create_shape(settings, opening) + verts = ifcopenshell.util.shape.get_vertices(shape.geometry) + shape_matrix = Matrix(ifcopenshell.util.shape.get_shape_matrix(shape).tolist()) + except Exception: + verts = None + shape_matrix = None + + if verts is None or shape_matrix is None or len(verts) == 0: + placement = Matrix(ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement).tolist()) + placement.translation *= unit_scale + _, t = mathutils.geometry.intersect_point_line(placement.translation.to_2d(), *axis_reference) + return t, t + + positions = [] + for v in verts: + world = (shape_matrix @ Vector((float(v[0]), float(v[1]), float(v[2])))).to_2d() + _, t = mathutils.geometry.intersect_point_line(world, *axis_reference) + positions.append(t) + return min(positions), max(positions) + + +def _add_void_copy(building_element, source_opening): + """Add an unfilled IfcOpeningElement to ``building_element`` whose + geometry and placement mirror ``source_opening``. Used when a filled + opening's void straddles a wall split — the filling stays on its wall, + but the void must also apply to the neighbour so its body gets cut.""" + void_copy = ifcopenshell.api.root.copy_class(tool.Ifc.get(), product=source_opening) + for fill_rel in list(void_copy.HasFillings or ()): + tool.Ifc.get().remove(fill_rel) + void_copy.VoidsElements[0].RelatingBuildingElement = building_element + if void_copy.ObjectPlacement and void_copy.ObjectPlacement.is_a("IfcLocalPlacement"): + if building_element.ObjectPlacement: + void_copy.ObjectPlacement.PlacementRelTo = building_element.ObjectPlacement + if source_opening.Representation: + void_copy.Representation = ifcopenshell.util.element.copy_deep( + tool.Ifc.get(), source_opening.Representation, exclude=["IfcGeometricRepresentationContext"] + ) + + class DumbWallJoiner: def __init__(self): self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) @@ -1084,8 +1473,11 @@ class DumbWallJoiner: if tool.Ifc.is_moved(wall1): bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall1) - axis1 = tool.Model.get_wall_axis(wall1) - intersect, cut_percentage = mathutils.geometry.intersect_point_line(target.to_2d(), *axis1["reference"]) + ref = tool.Wall.get_world_reference_line(wall1) + if ref is None: + return + axis_world_2d = (ref[0].to_2d(), ref[1].to_2d()) + intersect, cut_percentage = mathutils.geometry.intersect_point_line(target.to_2d(), *axis_world_2d) if cut_percentage < 0 or cut_percentage > 1 or tool.Cad.is_x(cut_percentage, (0, 1)): return @@ -1119,39 +1511,44 @@ class DumbWallJoiner: ) # During the duplication process, unfilled voids are copied, so we need - # to check openings on both element1 and element2. Let's check element1 - # first. + # to check openings on both element1 and element2. Each wall keeps the + # opening when the opening's axis-projected extent overlaps that wall's + # portion of the axis — straddling openings are intentionally kept on + # both walls so each wall body gets the appropriate cut. Strict + # inequalities mean a boundary-only touch (or a degenerate single-point + # extent at the cut) keeps the opening on both walls — the safer + # default when the helper cannot resolve a true bounding range. for opening in [ r.RelatedOpeningElement for r in element1.HasOpenings if not r.RelatedOpeningElement.HasFillings ]: - opening_matrix = Matrix(ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement).tolist()) - opening_matrix.translation *= unit_scale - opening_location = opening_matrix.translation - _, opening_position = mathutils.geometry.intersect_point_line(opening_location.to_2d(), *axis1["reference"]) - if opening_position > cut_percentage: - # The opening should be removed from element1. + min_t, _ = _opening_axis_extent(opening, axis_world_2d, unit_scale) + if min_t > cut_percentage: + # Opening lies entirely past the cut — only element2 should keep it. ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening) - # Now let's check element2. for opening in [ r.RelatedOpeningElement for r in element2.HasOpenings if not r.RelatedOpeningElement.HasFillings ]: - opening_matrix = Matrix(ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement).tolist()) - opening_matrix.translation *= unit_scale - opening_location = opening_matrix.translation - _, opening_position = mathutils.geometry.intersect_point_line(opening_location.to_2d(), *axis1["reference"]) - if opening_position < cut_percentage: - # The opening should be removed from element2. + _, max_t = _opening_axis_extent(opening, axis_world_2d, unit_scale) + if max_t < cut_percentage: + # Opening lies entirely before the cut — only element1 should keep it. ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening) # During the duplication process, filled voids are not copied. So we - # only need to check fillings on the original element1. - for opening in [r.RelatedOpeningElement for r in element1.HasOpenings if r.RelatedOpeningElement.HasFillings]: + # only need to check fillings on the original element1. The filling + # (door/window) belongs to whichever wall contains its center, but the + # void may need to apply to both walls when the void's extent straddles + # the cut — otherwise the neighbour wall's body would not be cut. + for opening in [ + r.RelatedOpeningElement for r in list(element1.HasOpenings) if r.RelatedOpeningElement.HasFillings + ]: rel = opening.HasFillings[0] filling = rel.RelatedBuildingElement filling_obj = tool.Ifc.get_object(filling) filling_location = filling_obj.matrix_world.translation - _, filling_position = mathutils.geometry.intersect_point_line(filling_location.to_2d(), *axis1["reference"]) + _, filling_position = mathutils.geometry.intersect_point_line(filling_location.to_2d(), *axis_world_2d) + min_t, max_t = _opening_axis_extent(opening, axis_world_2d, unit_scale) + void_straddles = min_t < cut_percentage < max_t if filling_position > cut_percentage: # The filling should be moved from element1 to element2. new_opening = ifcopenshell.api.root.copy_class(tool.Ifc.get(), product=opening) @@ -1165,11 +1562,20 @@ class DumbWallJoiner: tool.Ifc.get(), opening.Representation, exclude=["IfcGeometricRepresentationContext"] ) - rel.RelatedBuildingElement = element2 + rel.RelatingOpeningElement = new_opening # Remove the old opening ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening) + if void_straddles: + # Filling moved to element2, but void straddles — add a + # pure-void copy back to element1 so its body still gets cut. + _add_void_copy(element1, new_opening) + elif void_straddles: + # Filling stays on element1, but void straddles — add a pure-void + # copy to element2 so its body gets cut. + _add_void_copy(element2, opening) + p1, p2 = ifcopenshell.util.representation.get_reference_line(element1) p3 = (wall1.matrix_world.inverted() @ intersect.to_3d()).to_2d() / unit_scale self.set_axis(element1, p1, p3) @@ -1337,7 +1743,9 @@ class DumbWallJoiner: results["direction"] = Vector(item.ExtrudedDirection.DirectionRatios) results["x_angle"] = Vector((0, 1)).angle_signed(Vector((y, z))) results["is_sloped"] = True - results["height"] = (item.Depth * self.unit_scale) / abs(1 / cos(results["x_angle"])) + results["height"] = core.vertical_height_from_extrusion_depth( + item.Depth * self.unit_scale, results["x_angle"] + ) break elif item.is_a("IfcBooleanClippingResult"): # should be before IfcBooleanResult check item = item.FirstOperand @@ -1402,3 +1810,2976 @@ class DumbWallJoiner: ) return (i_top - i_bottom).length + + +class EnableEditingWall(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.enable_editing_wall" + bl_label = "Edit Wall" + bl_description = "Show wall edit gizmos" + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context: bpy.types.Context) -> set[str]: + obj = context.active_object + if not obj: + return {"CANCELLED"} + reason = tool.Wall.validate_for_parametric_edit(obj) + if reason: + self.report({"WARNING"}, f"Cannot edit wall parametrically: {reason}") + return {"CANCELLED"} + # If openings are currently shown for editing (via the Toggle Openings gizmo + # or the Alt+O hotkey), apply them before entering wall edit mode. Otherwise + # the wall enters edit mode with floating opening previews that don't reflect + # the IFC state the gizmos read from. + if tool.Model.get_model_props().openings: + bpy.ops.bim.edit_openings(apply_all=True) + props = tool.Model.get_wall_props(obj) + # Force is_editing False before populating so update_wall stays a no-op + # while we copy IFC state into the draft properties. + props.is_editing = False + _read_wall_state_into_props(obj, props) + # Mesh stays as the existing IFC-derived geometry until the first gizmo drag + # — that way an enable → ✓ round-trip with no drag is a true no-op. + props.mesh_dirty = False + props.is_editing = True + return {"FINISHED"} + + +class CancelEditingWall(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.cancel_editing_wall" + bl_label = "Discard Wall Edits" + bl_description = "Discard wall edits" + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context: bpy.types.Context) -> set[str]: + obj = context.active_object + if not obj: + return {"CANCELLED"} + props = tool.Model.get_wall_props(obj) + # Disable update_wall first so the snap restores don't redraw the preview. + props.is_editing = False + props.length = props.snap_length + props.height = props.snap_height + props.thickness = props.snap_thickness + props.offset = props.snap_offset + # If the user dragged before cancelling, the visible mesh is the simplified + # preview box (openings/layers stripped). Restore the real IFC-derived geometry + # so cancel feels like a true undo — equivalent to the user hitting S_G manually. + _restore_wall_mesh_if_dirty(obj) + return {"FINISHED"} + + +class FinishEditingWall(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.finish_editing_wall" + bl_label = "Apply Wall Edits" + bl_description = "Apply wall edits" + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context: bpy.types.Context) -> set[str]: + obj = context.active_object + if not obj: + return {"CANCELLED"} + element = tool.Ifc.get_entity(obj) + if not element: + return {"CANCELLED"} + props = tool.Model.get_wall_props(obj) + # No edit session in progress — finish is a true no-op. Without this guard, + # an enable that failed validation (e.g. wall without IfcMaterialLayerSetUsage) + # leaves is_editing=False but a press on finish still walks the sub-ops below, + # which dereference layer-set-dependent state and crash. + if not props.is_editing: + return {"CANCELLED"} + + length_changed = not tool.Cad.is_x(props.length, props.snap_length, tolerance=1e-5) + height_changed = not tool.Cad.is_x(props.height, props.snap_height, tolerance=1e-5) + x_angle_changed = not tool.Cad.is_x(props.x_angle, props.snap_x_angle, tolerance=1e-5) + baseline_changed = props.desired_offset_baseline != props.snap_offset_baseline + any_change = length_changed or height_changed or x_angle_changed or baseline_changed + + # Order matters: baseline shifts the layer-set reference line, then length + # adjusts endpoints relative to that, then x_angle changes the slope (and + # recomputes extrusion direction), and height is applied LAST so it reads the + # final x_angle when converting vertical-height ↔ extrusion-depth. Running + # height before x_angle made the slope op overwrite the just-set height. + # temp_override scopes each sub-op to this wall so the delegated operators + # don't fan out to other selected walls. + with bpy.context.temp_override(active_object=obj, selected_objects=[obj]): + if baseline_changed: + tool.Model.offset_wall(obj, props.desired_offset_baseline) + tool.Model.recalculate_walls([obj]) + tool.Model.get_model_props().offset_type_vertical = props.desired_offset_baseline + if length_changed: + DumbWallJoiner().set_length(obj, props.length) + tool.Model.recalculate_walls([obj]) + if x_angle_changed: + bpy.ops.bim.change_extrusion_x_angle(x_angle=props.x_angle) + if height_changed: + bpy.ops.bim.change_extrusion_depth(depth=props.height) + + if any_change: + props.mesh_dirty = False + else: + _restore_wall_mesh_if_dirty(obj) + # Set only on success: if any sub-op above raised, the draft survives for retry. + props.is_editing = False + return {"FINISHED"} + + +class CycleWallOffset(bpy.types.Operator): + bl_idname = "bim.cycle_wall_offset" + bl_label = "Cycle Wall Baseline" + bl_description = "Cycle wall baseline through Exterior, Centreline, Interior. Shift+click reverses" + bl_options = {"REGISTER", "UNDO"} + # Deliberately NOT a tool.Ifc.Operator: this operator never calls into + # ifcopenshell.api. Inheriting from Ifc.Operator would drag a draft-only + # property cycle into Bonsai's IFC undo transaction system. + + @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 + + # Same order the offset_type_vertical EnumProperty uses in prop.py. + _ORDER = ("EXTERIOR", "CENTER", "INTERIOR") + reverse: bpy.props.BoolProperty(name="Reverse", default=False, options={"HIDDEN", "SKIP_SAVE"}) + + def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: + self.reverse = event.shift + return self.execute(context) + + def execute(self, context: bpy.types.Context) -> set[str]: + obj = context.active_object + if not obj: + return {"CANCELLED"} + props = tool.Model.get_wall_props(obj) + if not props.is_editing: + self.report({"WARNING"}, "Cycle wall offset only works in wall edit mode.") + return {"CANCELLED"} + current = props.desired_offset_baseline + idx = self._ORDER.index(current) if current in self._ORDER else 0 + direction = -1 if self.reverse else 1 + props.desired_offset_baseline = self._ORDER[(idx + direction) % len(self._ORDER)] + return {"FINISHED"} + + +class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): + bl_idname = "OBJECT_GGT_bim_wall_edition" + bl_label = "Wall Editing Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + enable_editing_operator = "bim.enable_editing_wall" + finish_editing_operator = "bim.finish_editing_wall" + cancel_editing_operator = "bim.cancel_editing_wall" + # Empty disables the base class's auto-created cycle_gizmo at ICON_CYCLE_X. + # Three state-specific baseline icons (exterior / center / interior) take + # over that slot, with the active one chosen per frame from props. + cycle_type_operator = "" + + # Threshold (SI meters) above which a second height gizmo is drawn at the far end of + # the wall so the user doesn't have to pan across long walls to reach a height handle. + LONG_WALL_THRESHOLD = 5.0 + + dimension_gizmo_props = [ + # length / height / height_end positions are recomputed per frame in + # ``_update_dimension_gizmo_positions`` so they flip to the camera-facing + # side of the wall as the viewport is orbited. No static ``matrix_position`` + # here means the base class falls back to Identity, which the override + # then replaces with the view-dependent coordinates. + DimensionGizmoConfig( + attr_name="length", + axis=(1, 0, 0), + min_value=0.01, + text_offset_sign=-1, + ), + DimensionGizmoConfig( + attr_name="height", + axis=(0, 0, 1), + min_value=0.01, + ), + # Second height gizmo at the far end of long walls. Distinct attr_name so it + # doesn't collide with the first height gizmo in self.dimension_*_gizmo storage; + # compute/apply tunnel through to the same props.height. + DimensionGizmoConfig( + attr_name="height_end", + axis=(0, 0, 1), + min_value=0.01, + # default-arg captures the class const because lambda body can't see class scope. + visibility_condition=lambda p, _t=LONG_WALL_THRESHOLD: p.length > _t, + compute_value=lambda p: p.height, + apply_value=lambda p, v: setattr(p, "height", max(0.01, v)), + color="BLUE", + ), + # Slope: a Y-axis dimension at the top edge measuring horizontal displacement + # of the top face. compute/apply translate between displacement (what the user + # sees & drags) and x_angle (what's stored). Drag toward +Y → positive slope. + DimensionGizmoConfig( + attr_name="x_angle", + axis=(0, 1, 0), + prop_name="Slope", + matrix_position=lambda p: Vector((p.anchor_x + p.length / 2, p.offset + p.thickness / 2, p.height)), + compute_value=lambda p: core.displacement_from_x_angle(p.height, p.x_angle), + apply_value=lambda p, displacement: setattr( + p, "x_angle", core.x_angle_from_displacement(p.height, displacement) + ), + color="GREEN", + min_value=-1e6, # apply_value clamps via atan2; allow negative displacement + text_formatter=lambda p, displacement: ( + f"{'-' if displacement < 0 else ''}{tool.Unit.format_distance(abs(displacement))} " + f"({math.degrees(p.x_angle):.1f}°)" + ), + ), + ] + + props_getter = tool.Model.get_wall_props + gizmo_pref_name = "wall" + + @classmethod + def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool: + return tool.Parametric.is_wall(element) + + def get_icon_y_extent(self, props: "BIMWallProperties") -> tuple[float, float]: + far = props.offset + props.thickness + 2 * self.GIZMO_OFFSET + near = -props.offset + 2 * self.GIZMO_OFFSET + return (far, near) + + def _update_dimension_gizmo_positions( + self, context: bpy.types.Context, mw: Matrix, props: "BIMWallProperties" # noqa: ARG002 + ) -> None: + """Re-position length / height / height_end dimensions to the camera-facing + Y-side of the wall every frame. Mirrors the door & stair pattern: when the + viewport is orbited past the wall, the handles jump to the visible face + instead of being stranded behind it. + + - When viewing from -Y: place handles at wall-local Y = ``offset - GIZMO_OFFSET``. + - When viewing from +Y: place handles at wall-local Y = ``offset + thickness + GIZMO_OFFSET``. + + Slope (``x_angle``) is intentionally NOT view-flipped — it lives at the wall + axis centerline because the gizmo IS the Y-displacement indicator. Flipping + it would invert the drag direction relative to the user's pointer motion.""" + viewing_from_neg_y, _ = self._frame_view_dir + y_camera_side = self.get_camera_facing_outer_y( + viewing_from_neg_y, + props.offset, + props.offset + props.thickness, + self.GIZMO_OFFSET, + ) + # Length: along X axis at half-height, on the camera-facing edge. + self.set_dimension_gizmo_position( + "length", + mw, + Vector((props.anchor_x, y_camera_side, props.height / 2)), + (1, 0, 0), + ) + # Height (start of wall): along Z, at the start endpoint, camera-facing side. + self.set_dimension_gizmo_position( + "height", + mw, + Vector((props.anchor_x, y_camera_side, 0)), + (0, 0, 1), + ) + # Height (far end of long walls): along Z, at the end endpoint, camera-facing side. + self.set_dimension_gizmo_position( + "height_end", + mw, + Vector((props.anchor_x + props.length, y_camera_side, 0)), + (0, 0, 1), + ) + + # Per-region weakref map populated at setup time. The wall-gizmo preview + # decorator dereferences this each draw to read live ``is_highlight`` state + # off the cursor icons (extend-X / extend-Z / split) in the same region + # it's currently drawing in, so the GPU axis-preview lines only render + # while the matching icon is hovered. + _active_instances: ClassVar["dict[int, weakref.ReferenceType[GizmoWallEdition]]"] = {} + + # Row layout: validate / cancel / baseline-triplet / rotate / array. + # Wall has no ``cycle_type_operator``, so the cycle slot collapses and + # the baseline triplet takes the cycle X position (0.87). Rotate + # follows at 1.24. Both slots are declared here — the layout manager + # assigns the X positions from tuple order. + feature_slots: ClassVar[tuple[IconSlot, ...]] = ( + IconSlot( + name="baseline", + gizmo_idname="VIEW3D_GT_offset", + variants=("exterior", "center", "interior"), + operator="bim.cycle_wall_offset", + ), + IconSlot( + name="rotate", + gizmo_idname="VIEW3D_GT_cycle", + operator="bim.rotate_wall_90", + scale=0.30, + ), + ) + + # Idle-mode pen-row extras. The base class handles setup + per-frame + # positioning + visibility gating via ``visible_when``; this declaration + # is the only wall-specific code needed for the toggle-openings icon. + idle_slots: ClassVar[tuple[IconSlot, ...]] = ( + IconSlot( + name="toggle_openings", + gizmo_idname="VIEW3D_GT_add_opening", + operator="bim.toggle_host_openings", + visible_when=lambda gg: _wall_has_openings(gg), + ), + ) + + def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None: + """Wall-specific gizmos. + + Cursor-anchored (always visible during edit mode, conditional position): + + - ``split_gizmo`` — at the 3D cursor's exact world position when cursor is + within the wall's X range. Clicking splits the wall there. + - ``extend_x_gizmo`` — at the wall-local X of the cursor, projected to the + floor plane (Z=0 in wall-local). Clicking extends/trims the wall's length. + - ``extend_z_gizmo`` — at the wall-local X of the cursor, projected to the + wall top (Z=height in wall-local). Clicking extends the wall's height to + the cursor's Z. + - ``add_perpendicular_wall_gizmo`` — visible only when the cursor is + off-axis by more than ``CURSOR_STACK_OFFSET``. Sits at the cursor's + XY (X clamped to the wall's X-range) on the wall-local floor plane. + Clicking spawns a perpendicular branch wall; shift+click forms a corner. + + The baseline-state triplet (exterior/center/interior) and the rotate-90 + icon live in ``feature_slots`` — the base class handles creation and + edit-row positioning; this group only picks variant visibility per + frame in ``_update_icon_row_extras``. The idle-row ``toggle_openings`` + icon is declared in ``idle_slots`` and fully managed by the base.""" + default_color, highlight_color = self.get_decoration_colors() + self.split_gizmo = self._setup_icon_gizmo( + "VIEW3D_GT_split", + default_color, + "bim.split_wall_at_cursor", + highlight_color, + ) + self.extend_x_gizmo = self._setup_icon_gizmo( + "VIEW3D_GT_extend", + default_color, + "bim.extend_wall_to_cursor", + highlight_color, + ) + self.extend_z_gizmo = self._setup_icon_gizmo( + "VIEW3D_GT_extend_vertical", + default_color, + "bim.extend_wall_height_to_cursor", + highlight_color, + ) + self.add_perpendicular_wall_gizmo = self._setup_icon_gizmo( + "VIEW3D_GT_extend", + default_color, + "bim.add_perpendicular_wall", + highlight_color, + ) + if context.region is not None: + type(self)._active_instances[context.region.as_pointer()] = weakref.ref(self) + + def _refresh_element_specific(self, context: bpy.types.Context, mw: Matrix, props: "BIMWallProperties") -> None: + """Position cursor-anchored gizmos and the wall-specific icon-row extras.""" + self._update_cursor_gizmos(context, mw, props) + self._update_icon_row_extras(context, mw, props) + + # World-Z spacing between stacked cursor icons. ~0.3m is ~1.5× icon diameter + # at default scale, leaving a small visual gap between consecutive icons. + CURSOR_STACK_OFFSET = 0.3 + + def _stack_offset(self, stack_index: int, screen_up: Vector, clearance: Vector) -> Vector: + """World-space offset for the ``stack_index``-th icon in a cursor + row: ``clearance`` (top-down only) plus a screen-up step per slot. + Single source of truth for the cursor-row stacking discipline.""" + return clearance + screen_up * (stack_index * self.CURSOR_STACK_OFFSET) + + def _update_cursor_gizmos(self, context: bpy.types.Context, mw: Matrix, props: "BIMWallProperties") -> None: + """Position the cursor-anchored icons (extend-X / extend-Z / split) on the wall + axis at the cursor's projected X. + + Always visible when a parametric wall is selected — not gated on edit + mode. The three operators (``bim.extend_wall_to_cursor`` / + ``bim.extend_wall_height_to_cursor`` / ``bim.split_wall_at_cursor``) + all poll on wall-selected and commit any pending wall edit before + acting, so single-click without entering edit mode is the canonical + flow. The ``WallGizmoPreviewDecorator`` keeps the viewport clean by + only drawing the action's guide line on hover. + + Two branches by view orientation: + + - **Non-top-down**: world-Z stacking. Each icon sits at the Z its + action would land at (extend-X at floor, extend-Z at cursor Z, + split at wall top). Colliding icons stack at ``CURSOR_STACK_OFFSET`` + increments; priority low → high: extend-X, extend-Z, split. + - **Top-down (plan view)**: world-Z collapses to one screen point, + so the world-Z stack would invisibly pile every icon on top of + ``extend_x``. Drop ``extend_z`` (vertical intent has no readable + cue when looking down +Z) and stack the rest along screen-up at + the floor anchor.""" + if not hasattr(self, "split_gizmo"): + return + all_gizmos = ( + self.extend_x_gizmo, + self.extend_z_gizmo, + self.split_gizmo, + self.add_perpendicular_wall_gizmo, + ) + cursor_world = context.scene.cursor.location + cursor_local = mw.inverted() @ cursor_world + # ``props.anchor_x`` / ``props.length`` mirror IFC and are only refreshed + # when an operator calls ``_maybe_resync_wall_props_from_ifc``. Reading + # the live extent from the mesh bbox makes the gizmo position robust + # against any operator path that skips that re-sync — the mesh is + # always rebuilt by ``recreate_wall`` to match the current IFC body. + bbox_x = [v[0] for v in context.active_object.bound_box] if context.active_object else None + if bbox_x: + wall_anchor_x = min(bbox_x) + wall_length = max(bbox_x) - wall_anchor_x + else: + wall_anchor_x = props.anchor_x + wall_length = props.length + in_range = wall_anchor_x < cursor_local.x < wall_anchor_x + wall_length + billboard_rot = self._frame_billboard_rot + top_down = tool.Blender.is_view_top_down(context) + perp_params = _perpendicular_wall_params(cursor_local.x, cursor_local.y, wall_anchor_x, wall_length) + + # Candidates ordered by priority (lowest first). Each is (gizmo, local_z). + candidates: list[tuple[bpy.types.Gizmo, float]] = [(self.extend_x_gizmo, 0.0)] + if not top_down: + candidates.append((self.extend_z_gizmo, cursor_local.z)) + if in_range: + # Vertical (world-Z) height → wall-local Z so the icon lands on + # the slanted top edge for sloped walls (x_angle != 0). + split_local_z = core.extrusion_depth_from_vertical_height(props.height, props.x_angle) + candidates.append((self.split_gizmo, split_local_z)) + + # Resolve collisions: walk in priority order and ensure each gizmo's + # final Z is at least CURSOR_STACK_OFFSET above the previous one (when + # the previous one's final Z is higher). + resolved: list[tuple[bpy.types.Gizmo, float]] = [] + for gz, desired_z in candidates: + final_z = desired_z + for _, prev_z in resolved: + if abs(final_z - prev_z) < self.CURSOR_STACK_OFFSET: + final_z = prev_z + self.CURSOR_STACK_OFFSET + resolved.append((gz, final_z)) + + for gz in all_gizmos: + gz.hide = True + screen_up = tool.Blender.get_screen_up_world(context) + clearance = gizmo.top_down_clearance(context, billboard_rot) + if top_down: + # Swap world-Z stacking for screen-up stacking so each icon stays + # individually clickable when the camera projects world Z to zero. + # The shared ``top_down_clearance`` lifts the whole stack off the + # cursor so its small crosshair stays visible for precise pointing. + base_world = mw @ Vector((cursor_local.x, 0.0, 0.0)) + for index, (gz, _local_z) in enumerate(resolved): + gz.hide = self.is_gizmo_hidden_by_modal(gz) + world_pos = base_world + self._stack_offset(index, screen_up, clearance) + gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot) + _apply_wall_extend_flips(gz, self, world_pos, mw, cursor_local, props, billboard_rot) + else: + # World-Z stacking carries each icon's semantic Z (extend-X at + # floor, extend-Z at cursor Z, split at wall top). At shallow + # viewing angles a 0.3 m gap can still project to near-zero + # screen separation, so add a screen-up offset per stack slot + # — the world-Z position still drives the icon's meaning, the + # screen-up term is just visual insurance. + no_clearance = Vector((0.0, 0.0, 0.0)) + for index, (gz, local_z) in enumerate(resolved): + gz.hide = self.is_gizmo_hidden_by_modal(gz) + world_pos = mw @ Vector((cursor_local.x, 0.0, local_z)) + self._stack_offset( + index, screen_up, no_clearance + ) + gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot) + _apply_wall_extend_flips(gz, self, world_pos, mw, cursor_local, props, billboard_rot) + + if perp_params is not None: + # Stack the perpendicular gizmo one slot above the on-axis row + # along screen-up so it stays independently clickable when the + # cursor sits just past the dead zone. The arrow's in-plane + # rotation points its +X from the wall projection toward the + # cursor as a "new wall sprouts this way" cue. + clamped_x, _length, side_sign = perp_params + gz = self.add_perpendicular_wall_gizmo + gz.hide = self.is_gizmo_hidden_by_modal(gz) + perp_base = mw @ Vector((clamped_x, cursor_local.y, 0.0)) + perp_world = perp_base + self._stack_offset(len(resolved), screen_up, clearance) + perp_world_dir = (mw.to_3x3().col[1] * side_sign).normalized() + screen_dir = billboard_rot.transposed() @ perp_world_dir + angle = math.atan2(screen_dir.y, screen_dir.x) + gz.matrix_basis = gizmo.billboarded_at(perp_world, billboard_rot) @ Matrix.Rotation(angle, 4, "Z") + + # Map ``props.desired_offset_baseline`` (storage form) to the slot variant + # name. Centralised here so the variant strings stay aligned with the slot + # declaration in feature_slots. + _BASELINE_TO_VARIANT: ClassVar[dict[str, str]] = { + "EXTERIOR": "exterior", + "CENTER": "center", + "INTERIOR": "interior", + } + + def _update_icon_row_extras(self, context: bpy.types.Context, mw: Matrix, props: "BIMWallProperties") -> None: + """Pick which baseline variant is visible during edit. + + Baseline triplet: the base class's slot loop already wrote a billboard + matrix on each variant member at the same X (the cycle slot, since + wall has no ``cycle_type_operator``). This hook only flips ``hide`` + on each member based on ``props.desired_offset_baseline`` so exactly + one variant shows. The rotate-90 icon is a single-icon feature slot + and is fully handled by the base. The toggle-openings idle icon is + declared in ``idle_slots`` and positioned by the base.""" + active_variant = self._BASELINE_TO_VARIANT.get(props.desired_offset_baseline) + for variant in ("exterior", "center", "interior"): + gz = getattr(self, f"baseline_{variant}_gizmo", None) + if gz is None: + continue + if props.is_editing and variant == active_variant: + gz.hide = self.is_gizmo_hidden_by_modal(gz) + else: + gz.hide = True + + +def _apply_wall_extend_flips( + gz: bpy.types.Gizmo, + group: "GizmoWallEdition", + world_pos: Vector, + mw: Matrix, + cursor_local: Vector, + props: "BIMWallProperties", + billboard_rot: Matrix, +) -> None: + """Mirror the wall's extend arrows so each points toward the end the click will move. + + Extend-X: arrow points away from the wall endpoint that the operator would + keep fixed, accounting for the camera's screen-X orientation. Extend-Z: + arrow flips downward when the cursor sits below the wall top.""" + if gz is group.extend_x_gizmo: + if props.length > 0 and cursor_local.x > props.anchor_x + props.length / 2: + reference_x = props.anchor_x + else: + reference_x = props.anchor_x + props.length + reference_world = mw @ Vector((reference_x, 0.0, 0.0)) + if gizmo.should_flip_extend_arrow(world_pos, reference_world, billboard_rot): + gz.matrix_basis = gz.matrix_basis @ gizmo.EXTEND_FLIP_MIRROR_X + elif gz is group.extend_z_gizmo and cursor_local.z < props.height - gizmo.EXTEND_FLIP_EPSILON: + gz.matrix_basis = gz.matrix_basis @ gizmo.EXTEND_FLIP_MIRROR_Y + + +def _commit_active_wall_edit_if_any(context: bpy.types.Context) -> bpy.types.Object | None: + """Return the active object, committing any in-progress wall edit first. + + Used by the scissors/extend gizmo operators: clicking either icon implicitly + validates the current edit (✓ semantics) before running the follow-up action. + Returns None when there's no active object — callers should treat that as CANCELLED.""" + obj = context.active_object + if not obj: + return None + props = tool.Model.get_wall_props(obj) + if props.is_editing: + bpy.ops.bim.finish_editing_wall() + return obj + + +def _perpendicular_wall_params( + cursor_local_x: float, + cursor_local_y: float, + anchor_x: float, + length: float, +) -> tuple[float, float, float] | None: + """Geometry of a perpendicular branch wall sprouting from the cursor's + projection on the source wall axis. + + Returns ``(clamped_x, perpendicular_length, side_sign)`` — the projection + on the wall axis (clamped to ``[anchor_x, anchor_x + length]``), the + branch wall length, and the side (+1 / -1) the branch sits on. Returns + ``None`` when the cursor sits within ``CURSOR_STACK_OFFSET`` of the + source wall axis (the on-wall dead zone).""" + if abs(cursor_local_y) <= GizmoWallEdition.CURSOR_STACK_OFFSET: + return None + clamped_x = max(anchor_x, min(anchor_x + length, cursor_local_x)) + side_sign = 1.0 if cursor_local_y > 0 else -1.0 + return clamped_x, abs(cursor_local_y), side_sign + + +def _commit_pending_wall_edits_for_selection(context: bpy.types.Context) -> None: # noqa: ARG001 + """Thin wall-scoped alias for ``tool.Parametric.commit_pending_edits_for_selection``. + + Encapsulates the ``names=("wall",)`` filter so the registry name is + touched in exactly one place.""" + tool.Parametric.commit_pending_edits_for_selection(names=("wall",)) + + +class SplitWallAtCursor(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.split_wall_at_cursor" + bl_label = "Split Wall at Cursor" + bl_description = "Split wall at 3D cursor location" + 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]: + # Applies any pending wall edit first so the split operates on the committed + # geometry rather than the draft preview box. + obj = _commit_active_wall_edit_if_any(context) + if obj is None: + return {"CANCELLED"} + bpy.ops.bim.split_wall() + _maybe_resync_wall_props_from_ifc(obj) + return {"FINISHED"} + + +class ExtendWallToCursor(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.extend_wall_to_cursor" + bl_label = "Extend Wall to Cursor" + bl_description = "Extend wall length to 3D cursor location" + 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]: + if _commit_active_wall_edit_if_any(context) is None: + return {"CANCELLED"} + core.extend_walls( + tool.Ifc, + tool.Blender, + tool.Geometry, + DumbWallJoiner(), + tool.Model, + context.scene.cursor.location, + ) + _resync_walls_after_mutation(tool.Blender.get_selected_objects()) + return {"FINISHED"} + + +class ExtendWallHeightToCursor(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.extend_wall_height_to_cursor" + bl_label = "Extend Wall Height to Cursor Z" + bl_description = "Extend wall height to 3D cursor Z location" + 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]: + obj = _commit_active_wall_edit_if_any(context) + if obj is None: + return {"CANCELLED"} + cursor_z = context.scene.cursor.location.z + base_z = obj.matrix_world.translation.z + new_height = cursor_z - base_z + if new_height <= 0: + self.report( + {"WARNING"}, + f"Cursor Z ({cursor_z:.2f}m) must be above wall base ({base_z:.2f}m).", + ) + return {"CANCELLED"} + with bpy.context.temp_override(active_object=obj, selected_objects=[obj]): + bpy.ops.bim.change_extrusion_depth(depth=new_height) + _maybe_resync_wall_props_from_ifc(obj) + return {"FINISHED"} + + +class AddPerpendicularWall(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.add_perpendicular_wall" + bl_label = "Add Perpendicular Wall at Cursor" + bl_description = ( + "Create a new wall perpendicular to the active wall, from the cursor's " + "orthogonal projection on the wall axis toward the cursor. " + "Shift+Click for corner junction: the source wall is trimmed at the " + "projection, keeping its longer portion." + ) + bl_options = {"REGISTER", "UNDO"} + + use_corner_junction: bpy.props.BoolProperty(default=False) + + @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 invoke(self, context, event): + self.use_corner_junction = bool(event.shift) + return self.execute(context) + + def _execute(self, context: bpy.types.Context) -> set[str]: + source_obj = _commit_active_wall_edit_if_any(context) + if source_obj is None: + return {"CANCELLED"} + source_element = tool.Ifc.get_entity(source_obj) + if source_element is None: + self.report({"WARNING"}, "Active object is not an IFC element.") + return {"CANCELLED"} + source_type = ifcopenshell.util.element.get_type(source_element) + if source_type is None: + self.report({"WARNING"}, "Active wall has no IfcWallType; cannot derive branch wall.") + return {"CANCELLED"} + props = tool.Model.get_wall_props(source_obj) + cursor_local = source_obj.matrix_world.inverted() @ context.scene.cursor.location + params = _perpendicular_wall_params(cursor_local.x, cursor_local.y, props.anchor_x, props.length) + if params is None: + self.report({"INFO"}, "Cursor is on the wall axis; nothing to do.") + return {"CANCELLED"} + clamped_x, perpendicular_length, side_sign = params + start_world = source_obj.matrix_world @ Vector((clamped_x, 0.0, 0.0)) + source_z_rotation = source_obj.matrix_world.to_euler().z + new_z_rotation = source_z_rotation + side_sign * (pi / 2) + + # Shift+click L-corners the new wall against an endpoint of the + # source wall: the source is trimmed at the projection, keeping + # its longer of the two portions. + if self.use_corner_junction: + DumbWallJoiner().extend(source_obj, start_world) + + source_layers = tool.Model.get_material_layer_parameters(source_element) + + generator = DumbWallGenerator(source_type) + generator.file = tool.Ifc.get() + generator.layers = tool.Model.get_material_layer_parameters(source_type) + if not generator.layers["thickness"]: + self.report({"WARNING"}, "Wall type has no layer thickness; cannot create branch wall.") + return {"CANCELLED"} + generator.body_context = ifcopenshell.util.representation.get_context( + tool.Ifc.get(), "Model", "Body", "MODEL_VIEW" + ) + generator.axis_context = ifcopenshell.util.representation.get_context( + tool.Ifc.get(), "Plan", "Axis", "GRAPH_VIEW" + ) + generator.container = None + generator.container_obj = None + generator.width = generator.layers["thickness"] + generator.height = props.height + generator.length = perpendicular_length + generator.rotation = new_z_rotation + generator.location = start_world + generator.x_angle = 0.0 + new_obj = generator.create_wall() + new_element = tool.Ifc.get_entity(new_obj) + + # Branch wall inherits the source wall's centerline / offset baseline + # so the new axis lines up with the source's authored alignment rather + # than the type's default. + source_baseline = core.baseline_from_offset(source_layers["offset"], source_layers["thickness"]) + tool.Model.offset_wall(new_obj, source_baseline) + + ifcopenshell.api.geometry.connect_wall( + tool.Ifc.get(), + wall1=new_element, + wall2=source_element, + is_atpath=not self.use_corner_junction, + ) + + source_container = ifcopenshell.util.element.get_container(source_element) + if source_container is not None: + bonsai.core.spatial.assign_container( + tool.Ifc, tool.Collector, tool.Spatial, container=source_container, objs=[new_obj] + ) + + tool.Model.recreate_wall(source_element, source_obj) + tool.Model.recreate_wall(new_element, new_obj) + + tool.Blender.deselect_object(source_obj, ensure_active_object=False) + tool.Blender.set_active_object(new_obj) + + _resync_walls_after_mutation([source_obj, new_obj]) + return {"FINISHED"} + + +class RotateWall90(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.rotate_wall_90" + bl_label = "Rotate Wall 90°" + bl_description = "Rotate wall 90° around Z axis" + 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]: + obj = _commit_active_wall_edit_if_any(context) + if obj is None: + return {"CANCELLED"} + with bpy.context.temp_override(active_object=obj, selected_objects=[obj]): + bpy.ops.bim.rotate_90(axis="Z") + return {"FINISHED"} + + +def _wall_axis_world_segment_from_geom(obj: bpy.types.Object, geom: dict) -> tuple[Vector, Vector]: + """Compose the world-space axis segment from an already-read ``geom`` dict. + Used by the billboarding gizmo groups so a single cached IFC read drives both + ``tool.Wall.read_geometry`` *and* the segment, avoiding two reads per wall per frame.""" + p1_local = Vector((geom["anchor_x"], 0.0, 0.0)) + p2_local = Vector((geom["anchor_x"] + geom["length"], 0.0, 0.0)) + return obj.matrix_world @ p1_local, obj.matrix_world @ p2_local + + +class _WallGeomCachedBillboardingMixin(gizmo.BillboardingGizmoGroupMixin): + """Adds IFC-read caching to `BillboardingGizmoGroupMixin` for wall-driven + gizmo groups. ``refresh()`` is Blender's "something state-relevant changed" + signal — that's when we drop the cache. ``draw_prepare()`` (every redraw) reuses + whatever ``_get_wall_geom_cached`` populated, so plain camera orbits don't re-hit + IFC. ``_get_wall_geom_cached`` also drops entries on its own when + `tool.Parametric.get_geom_generation` advances (any ``tool.Ifc.Operator`` + commit) so external ``bpy.ops`` mutations on the same selection don't leave + stale geometry behind.""" + + def refresh(self, context: bpy.types.Context) -> None: + self._wall_geom_cache = None + self._wall_connections_cache = None + self._wall_pair_predicate_cache = None + self.position_gizmos(context) + + +def _get_wall_geom_cached(group: "bpy.types.GizmoGroup", obj: bpy.types.Object) -> dict | None: + """Per-gizmo-group memoised ``tool.Wall.read_geometry``. Without this, a + billboarding gizmo group re-runs the IFC read on every camera orbit frame — + ~120 IFC queries per second per wall, which is unwieldy on dense models. + + Two invalidation paths: + + - ``GizmoGroup.refresh()`` (Blender's state-change hook — selection, + gizmo modal exit, …) clears ``_wall_geom_cache`` directly. + - ``tool.Parametric.refresh_post_commit()`` bumps a generation counter on + every IFC operator commit; the cache stores the generation it was filled + at and drops on mismatch. This catches ``bpy.ops.bim.*`` mutations that + edit the wall while the same selection is held (the case Blender's + ``refresh()`` doesn't fire on).""" + current_gen = tool.Parametric.get_geom_generation() + cache_gen = getattr(group, "_wall_geom_cache_gen", None) + cache = getattr(group, "_wall_geom_cache", None) + if cache is None or cache_gen != current_gen: + cache = {} + group._wall_geom_cache = cache + group._wall_geom_cache_gen = current_gen + key = obj.name + if key not in cache: + cache[key] = tool.Wall.read_geometry(obj) + return cache[key] + + +def _get_wall_connections_cached( + group: "bpy.types.GizmoGroup", + elem: ifcopenshell.entity_instance, +) -> "list[tuple[ifcopenshell.entity_instance, str, str]]": + """Per-gizmo-group memoised ``_iter_path_connections``. Same generation-key + invalidation as ``_get_wall_geom_cached`` so an IFC mutation drops the cached + list on the next frame; ``refresh()`` drops it on selection change.""" + current_gen = tool.Parametric.get_geom_generation() + cache_gen = getattr(group, "_wall_connections_cache_gen", None) + cache = getattr(group, "_wall_connections_cache", None) + if cache is None or cache_gen != current_gen: + cache = {} + group._wall_connections_cache = cache + group._wall_connections_cache_gen = current_gen + key = elem.GlobalId + if key not in cache: + cache[key] = _iter_path_connections(elem) + return cache[key] + + +def _get_wall_pair_predicate_cached(group: "bpy.types.GizmoGroup", key: tuple, compute): + """Per-gizmo-group memo for wall-pair predicates (joined / collinear / + intersection). Caller supplies the cache key (typically pair GlobalIds + + relevant inputs like matrix_world tuples + thresholds) and a zero-arg + callable that computes the value on miss. Same generation invalidation as + the geom cache; ``refresh()`` drops it on selection change.""" + current_gen = tool.Parametric.get_geom_generation() + cache_gen = getattr(group, "_wall_pair_predicate_cache_gen", None) + cache = getattr(group, "_wall_pair_predicate_cache", None) + if cache is None or cache_gen != current_gen: + cache = {} + group._wall_pair_predicate_cache = cache + group._wall_pair_predicate_cache_gen = current_gen + if key not in cache: + cache[key] = compute() + return cache[key] + + +def _wall_camera_facing_icon_y(context: bpy.types.Context, mw: Matrix, geom: dict) -> float: + """Wall-local Y for an icon that should sit just outside the camera-facing face. + Centralised so the billboarding wall gizmos (add-opening, extend-vertically, …) + share one source of truth for "where does the icon go on the visible side".""" + viewing_from_negative_y, _ = gizmo.BaseParametricGizmoGroup.get_local_view_direction(context, mw) + return gizmo.BaseParametricGizmoGroup.get_camera_facing_outer_y( + viewing_from_negative_y, + geom["offset"], + geom["offset"] + geom["thickness"], + gizmo.BaseParametricGizmoGroup.GIZMO_OFFSET, + ) + + +def _are_walls_joined(elem_a: ifcopenshell.entity_instance, elem_b: ifcopenshell.entity_instance) -> bool: + """True if there's an ``IfcRelConnectsPathElements`` relating these two walls. + + Bonsai's wall joiner creates ``IfcRelConnectsPathElements`` (a specialization of + ``IfcRelConnectsElements``) whenever walls share a corner or mitre. We walk both + inverse arrays of the first wall and look for the second wall on the other side + of any path-element rel.""" + for rel in getattr(elem_a, "ConnectedTo", []): + if rel.is_a("IfcRelConnectsPathElements") and rel.RelatedElement == elem_b: + return True + for rel in getattr(elem_a, "ConnectedFrom", []): + if rel.is_a("IfcRelConnectsPathElements") and rel.RelatingElement == elem_b: + return True + return False + + +def _are_walls_collinear( + seg_a: tuple[Vector, Vector], + seg_b: tuple[Vector, Vector], + parallel_threshold: float = 0.9994, + line_tolerance: float = 0.05, +) -> bool: + """Vector wrapper around `core.are_axes_collinear` — converts Vector + endpoints to plain tuples at the boundary so the math stays unit-testable in + ``test/core/`` without a mathutils dependency.""" + return core.are_axes_collinear( + (tuple(seg_a[0]), tuple(seg_a[1])), + (tuple(seg_b[0]), tuple(seg_b[1])), + parallel_threshold, + line_tolerance, + ) + + +def _collinear_boundary_world(seg_a: tuple[Vector, Vector], seg_b: tuple[Vector, Vector]) -> Vector: + """Vector wrapper around `core.closest_endpoint_midpoint`.""" + return Vector( + core.closest_endpoint_midpoint( + (tuple(seg_a[0]), tuple(seg_a[1])), + (tuple(seg_b[0]), tuple(seg_b[1])), + ) + ) + + +def _classify_wall_join_state( + elem_a: ifcopenshell.entity_instance, + elem_b: ifcopenshell.entity_instance, + seg_a: tuple[Vector, Vector], + seg_b: tuple[Vector, Vector], + parallel_threshold: float, + collinear_tolerance: float, +) -> "tuple[core.WallJoinState, Optional[tuple[float, float, float]]]": + """``(state, intersection)`` — intersection is non-``None`` only on + the ``"intersect"`` branch.""" + return core.classify_wall_join_state( + (tuple(seg_a[0]), tuple(seg_a[1])), + (tuple(seg_b[0]), tuple(seg_b[1])), + are_joined=_are_walls_joined(elem_a, elem_b), + parallel_threshold=parallel_threshold, + collinear_tolerance=collinear_tolerance, + ) + + +def _iter_path_connections( + elem: ifcopenshell.entity_instance, +) -> list[tuple[ifcopenshell.entity_instance, str, str]]: + """For each ``IfcRelConnectsPathElements`` involving ``elem``, yield + ``(other_element, self_connection_type, other_connection_type)``. + + Walks both inverse arrays (``ConnectedTo`` + ``ConnectedFrom``) so the orientation + of each rel is normalised to "self first". Non-wall partners are skipped — a wall + MAY share a path connection with non-wall elements, but the unjoin gizmo only + exposes wall-to-wall joins to match the existing two-wall gizmo's scope.""" + out: list[tuple[ifcopenshell.entity_instance, str, str]] = [] + for rel in getattr(elem, "ConnectedTo", []): + if not rel.is_a("IfcRelConnectsPathElements"): + continue + other = rel.RelatedElement + # Malformed / partial IFC files can leave a rel's element ref unset. + # The partner predicate calls `.is_a(...)` on its argument, so a None + # would raise mid-frame and silently break the gizmo group — guard + # before the predicate runs. + if other is None or not tool.Parametric.is_path_connectable_wall(other): + continue + out.append((other, rel.RelatingConnectionType, rel.RelatedConnectionType)) + for rel in getattr(elem, "ConnectedFrom", []): + if not rel.is_a("IfcRelConnectsPathElements"): + continue + other = rel.RelatingElement + if other is None or not tool.Parametric.is_path_connectable_wall(other): + continue + out.append((other, rel.RelatedConnectionType, rel.RelatingConnectionType)) + return out + + +def _wall_fillet_props(context: bpy.types.Context): + return preview_base.get_preview_props(context, "wall_fillet") + + +_FILLET_SLOPE_TOLERANCE_RAD = 1e-4 + + +def _walls_have_zero_slope_for_fillet(operator: bpy.types.Operator, *walls: bpy.types.Object) -> bool: + """``True`` iff every input wall is vertical (``x_angle`` ~ 0). Reports an + ERROR on the operator and returns ``False`` otherwise. Slanted-extrusion + fillets require swept-along-curve geometry that the banana profile builder + isn't designed for — block the entry points so the user sees a clear + explanation instead of malformed corner geometry.""" + for wall in walls: + if wall is None: + continue + element = tool.Ifc.get_entity(wall) + if element is None: + continue + x_angle = tool.Wall.get_x_angle(element) + if x_angle is None: + continue + if abs(x_angle) > _FILLET_SLOPE_TOLERANCE_RAD: + operator.report( + {"ERROR"}, + "Wall fillet is not supported for slanted walls (non-zero slope). " + "Reset the wall's slope to vertical and try again.", + ) + return False + return True + + +def _build_curved_corner_body_representation( + ifc_file: ifcopenshell.file, + body_context: ifcopenshell.entity_instance, + arc_center_local: tuple[float, float, float], + chord_length_si: float, + radius_si: float, + r_outer_si: float, + r_inner_si: float, + height_si: float, +) -> ifcopenshell.entity_instance: + """Build an ``IfcShapeRepresentation`` with a banana (annular sector) + ``IfcExtrudedAreaSolid``. + + Local frame: origin at ``tangent_a``, +X along the chord to ``tangent_b``, + +Z vertical. ``r_outer_si`` / ``r_inner_si`` come from wall A's + ``IfcMaterialLayerSetUsage`` so the cross-section matches A at + ``tangent_a`` rather than centring on the reference arc.""" + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) + + cx_si, cy_si, _ = arc_center_local + dir_a = (-cx_si / radius_si, -cy_si / radius_si) + dir_b = ((chord_length_si - cx_si) / radius_si, -cy_si / radius_si) + + # Tessellate the banana profile as an IfcIndexedPolyCurve of straight + # IfcLineIndex segments rather than analytical trimmed-circle arcs: + # IfcOpenShell's geometry kernel and tool.Model.import_profile's edit-mode + # importer both handle polyline segments unconditionally; trimmed-circle + # alternatives fall through both paths to a coarse fallback or a hard error. + # 24 chord segments per arc is visually smooth and round-trip-stable. + arc_resolution = 24 + cross_z = dir_a[0] * dir_b[1] - dir_a[1] * dir_b[0] + theta_a = math.atan2(dir_a[1], dir_a[0]) + theta_b = math.atan2(dir_b[1], dir_b[0]) + # Take the SHORT angular sweep from theta_a to theta_b. CCW (positive + # signed cross product) means walking in increasing-theta direction. + sweep = theta_b - theta_a + if cross_z >= 0: + if sweep < 0: + sweep += 2 * math.pi + else: + if sweep > 0: + sweep -= 2 * math.pi + + def _arc_points(radius: float) -> list[tuple[float, float]]: + out = [] + for i in range(arc_resolution + 1): + theta = theta_a + sweep * (i / arc_resolution) + out.append((cx_si + radius * math.cos(theta), cy_si + radius * math.sin(theta))) + return out + + # Closed loop in counter-clockwise order: outer arc, radial step to inner + # arc, inner arc walked backwards, radial step back to outer start. The + # outer-to-inner and inner-to-outer steps are pure radial lines because + # the arcs share their endpoint angles. + outer_points = _arc_points(r_outer_si) + inner_points_reversed = list(reversed(_arc_points(r_inner_si))) + raw_points = outer_points + inner_points_reversed + points_ifc = [(x / unit_scale, y / unit_scale) for x, y in raw_points] + + point_list = ifc_file.createIfcCartesianPointList2D(points_ifc) + # Indices are 1-based per IFC schema. The curve auto-closes by referencing + # the first point as the next-segment start; the explicit closing segment + # survives writers that don't honour implicit close. + n = len(points_ifc) + segments = [ifc_file.createIfcLineIndex((i + 1, ((i + 1) % n) + 1)) for i in range(n)] + curve = ifc_file.createIfcIndexedPolyCurve(point_list, segments, False) + profile = ifc_file.createIfcArbitraryClosedProfileDef("AREA", None, curve) + + extrusion = ifc_file.createIfcExtrudedAreaSolid( + profile, + 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)), + ), + ifc_file.createIfcDirection((0.0, 0.0, 1.0)), + height_si / unit_scale, + ) + return ifc_file.createIfcShapeRepresentation( + body_context, body_context.ContextIdentifier, "SweptSolid", [extrusion] + ) + + +def _apply_fillet_corner_geometry( + ifc_file: ifcopenshell.file, + corner_obj: bpy.types.Object, + geom: dict, + wall_a_obj: bpy.types.Object, +) -> tuple[Vector, Vector, Vector, float] | None: + """Position the corner wall at ``tangent_a`` and rebuild its banana body + from ``geom``. Shared by the creation and regenerate paths so a + neighbour-driven recalc matches creation-time output even when wall A's + layer set has been edited since. + + Returns ``(x_dir, y_dir, z_dir, chord_length_si)`` on success or ``None`` + on degenerate chord / missing Body context. All probes run before any + mutation, so failures leave the corner wall untouched.""" + tangent_a = Vector(geom["tangent_a"]) + tangent_b = Vector(geom["tangent_b"]) + chord = tangent_b - tangent_a + chord_length_si = chord.length + if chord_length_si < 1e-6: + return None + body_context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW") + if body_context is None: + return None + + # Project the chord to the XY plane for the local-frame X axis. The + # corner wall's Z axis is hardcoded to world Z below, so an XY-aligned + # X axis is required for an orthonormal rotation matrix. Without the + # projection, any chord Z component (walls placed at different + # elevations) leaves x_dir non-orthogonal to z_dir and Blender's + # Euler decomposition surfaces the skew as spurious sub-degree X/Y + # rotations on the corner. + chord_xy = Vector((chord.x, chord.y, 0.0)) + if chord_xy.length < 1e-6: + return None + x_dir = chord_xy.normalized() + z_dir = Vector((0.0, 0.0, 1.0)) + y_dir = z_dir.cross(x_dir).normalized() + corner_obj.matrix_world = Matrix( + ( + (x_dir.x, y_dir.x, z_dir.x, tangent_a.x), + (x_dir.y, y_dir.y, z_dir.y, tangent_a.y), + (x_dir.z, y_dir.z, z_dir.z, tangent_a.z), + (0.0, 0.0, 0.0, 1.0), + ) + ) + bonsai.core.geometry.edit_object_placement( + tool.Ifc, tool.Geometry, tool.Surveyor, obj=corner_obj, apply_scale=False + ) + + arc_center_world = Vector(geom["arc_center"]) + v_world = arc_center_world - tangent_a + arc_center_local = (v_world.dot(x_dir), v_world.dot(y_dir), v_world.dot(z_dir)) + + # Banana cross-section side: ``side_sign`` picks whether the body endpoints + # extend toward the arc center (s = -1) or away from it (s = +1), so the + # cross-section at tangent_a matches wall A's body span instead of being + # centred on the reference arc. + radial_a_world = tangent_a - arc_center_world + if radial_a_world.length > 1e-6: + radial_a_world = radial_a_world.normalized() + wall_a_y_world = wall_a_obj.matrix_world.col[1].to_3d().normalized() + side_sign = 1.0 if wall_a_y_world.dot(radial_a_world) >= 0.0 else -1.0 + else: + side_sign = -1.0 + + # ``arc_radius`` is signed (negative = inverted fillet); banana radii use + # the magnitude — the sign only flips which side of A's reference line + # the arc center sits on, not the curve radii themselves. + radius_si = abs(geom["arc_radius"]) + offset_si = geom["profile_offset"] or 0.0 + thickness_si = geom["profile_thickness"] + r_endpoint_1 = abs(radius_si + side_sign * offset_si) + r_endpoint_2 = abs(radius_si + side_sign * (offset_si + thickness_si)) + r_outer_si = max(r_endpoint_1, r_endpoint_2) + r_inner_si = min(r_endpoint_1, r_endpoint_2) + + new_body = _build_curved_corner_body_representation( + ifc_file, + body_context, + arc_center_local=arc_center_local, + chord_length_si=chord_length_si, + radius_si=radius_si, + r_outer_si=r_outer_si, + r_inner_si=r_inner_si, + height_si=geom["height"] or 3.0, + ) + tool.Model.replace_object_ifc_representation(body_context, corner_obj, new_body) + return x_dir, y_dir, z_dir, chord_length_si + + +def _resolve_two_walls(context: bpy.types.Context) -> tuple[bpy.types.Object, bpy.types.Object] | None: + """``(active, other)`` from a 2-wall selection, both LAYER2 with straight axes.""" + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 2: + return None + active = context.active_object + if active is None or active not in selected: + return None + other = next((o for o in selected if o is not active), None) + if other is None: + return None + for obj in (active, other): + element = tool.Ifc.get_entity(obj) + if element is None or not element.is_a("IfcWall"): + return None + if not tool.Wall.has_layer2_usage(element): + return None + if not tool.Wall.is_straight_axis(element): + return None + if tool.Parametric.is_fillet_corner_wall(element): + # Re-filleting a curved corner would treat its chord as the + # reference line and produce nonsense geometry. + return None + return active, other + + +def _pick_dominant_wall_material( + element: ifcopenshell.entity_instance, +) -> Optional[ifcopenshell.entity_instance]: + """Return a single ``IfcMaterial`` representative of ``element``'s effective + material — the thickest layer's material when the element resolves to a + layer set / usage, the material itself when it is already plain, or + ``None`` for unsupported set kinds and elements with no material.""" + material = tool.Material.get_material(element, should_inherit=True) + if material is None: + return None + if material.is_a("IfcMaterial"): + return material + layer_set = None + if material.is_a("IfcMaterialLayerSetUsage"): + layer_set = material.ForLayerSet + elif material.is_a("IfcMaterialLayerSet"): + layer_set = material + if layer_set is None: + return None + layers_with_material = [layer for layer in (layer_set.MaterialLayers or ()) if layer.Material is not None] + if not layers_with_material: + return None + thickest = max(layers_with_material, key=lambda layer: layer.LayerThickness or 0.0) + return thickest.Material + + +def regenerate_fillet_corner_wall(element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None: + """Rebuild a fillet corner wall's banana body from ``BBIM_Wall.FilletRadius`` + and its neighbours' current layer parameters.""" + ifc_file = tool.Ifc.get() + if ifc_file is None: + return + radius_si = ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "FilletRadius") + if not radius_si: + return + + # Find the two neighbor walls from IfcRelConnectsPathElements. The corner- + # side connection type is NOTDEFINED so neighbours don't miter against the + # chord-axis reference line — take the single rel on each side of the + # corner's inverse graph rather than filtering on type. + wall_a = None + for rel in getattr(element, "ConnectedFrom", []): + if rel.is_a("IfcRelConnectsPathElements"): + wall_a = rel.RelatingElement + break + wall_b = None + for rel in getattr(element, "ConnectedTo", []): + if rel.is_a("IfcRelConnectsPathElements"): + wall_b = rel.RelatedElement + break + if wall_a is None or wall_b is None: + return + wall_a_obj = tool.Ifc.get_object(wall_a) + wall_b_obj = tool.Ifc.get_object(wall_b) + if wall_a_obj is None or wall_b_obj is None: + return + + geom = tool.Wall.compute_wall_fillet_geometry(wall_a_obj, wall_b_obj, float(radius_si)) + if geom is None or not geom["valid"]: + return + + # Re-anchors the corner's ObjectPlacement at the new tangent_a and rebuilds + # the banana body. If a neighbour moved, the new placement follows; if + # neither moved, the new matrix equals the old within floating-point noise. + _apply_fillet_corner_geometry(ifc_file, obj, geom, wall_a_obj) + + +class EnableWallFilletPreview(bpy.types.Operator): + """Enter wall-fillet preview mode for two selected walls. No IFC + mutation until finish.""" + + bl_idname = "bim.enable_wall_fillet_preview" + bl_label = "Enter Wall Fillet Preview" + bl_description = "Begin tuning the fillet radius before committing the rounded corner" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if _resolve_two_walls(context) is None: + cls.poll_message_set("Select exactly 2 LAYER2 walls with straight axes.") + return False + return True + + def execute(self, context): + walls = _resolve_two_walls(context) + if walls is None: + self.report({"ERROR"}, "Selection no longer eligible for fillet preview.") + return {"CANCELLED"} + wall_a, wall_b = walls + + elem_a = tool.Ifc.get_entity(wall_a) + elem_b = tool.Ifc.get_entity(wall_b) + + if not _walls_have_zero_slope_for_fillet(self, wall_a, wall_b): + return {"CANCELLED"} + + # Joined / intersecting only; parallel pairs have no corner to round. + seg_a = tool.Wall.get_world_reference_line(wall_a) + seg_b = tool.Wall.get_world_reference_line(wall_b) + if seg_a is None or seg_b is None: + self.report({"ERROR"}, "Could not read reference line on one of the walls.") + return {"CANCELLED"} + are_joined = _are_walls_joined(elem_a, elem_b) + state, _ = core.classify_wall_join_state( + (tuple(seg_a[0]), tuple(seg_a[1])), + (tuple(seg_b[0]), tuple(seg_b[1])), + are_joined, + core.PARALLEL_DOT_THRESHOLD, + core.COLLINEAR_LINE_TOLERANCE, + ) + if state not in {"intersect", "joined"}: + self.report({"ERROR"}, f"Fillet requires intersecting or joined walls (state was {state}).") + return {"CANCELLED"} + + preview_base.sync_uncommitted_moves([wall_a, wall_b]) + + props = _wall_fillet_props(context) + if props is None: + self.report({"ERROR"}, "Wall fillet preview state is unavailable.") + return {"CANCELLED"} + + # Auto-cancel any prior preview before opening a fresh one — fillet + # creates a new IFC entity at finish. + if props.is_active: + bpy.ops.bim.cancel_wall_fillet_preview() + + # Default radius: a fraction of the shorter available leg, clamped + # against the tangent-overshoot upper bound. + geom = tool.Wall.compute_wall_fillet_geometry(wall_a, wall_b, radius=_FILLET_DEFAULT_RADIUS_M) + default_radius = _FILLET_DEFAULT_RADIUS_M + if geom is not None and geom.get("sweep_angle") and geom["sweep_angle"] > 1e-3: + leg_a_available = geom.get("leg_a_available") or 0.0 + leg_b_available = geom.get("leg_b_available") or 0.0 + shortest_leg = min(leg_a_available, leg_b_available) + if shortest_leg > 1e-6: + upper = shortest_leg / max(math.tan(geom["sweep_angle"] / 2), 1e-6) + default_radius = max( + _FILLET_MIN_RADIUS_M, + min(_FILLET_DEFAULT_LEG_FRACTION * shortest_leg, upper, _FILLET_DEFAULT_RADIUS_M), + ) + + props.wall_a_id = elem_a.id() + props.wall_b_id = elem_b.id() + props.radius = default_radius + props.editing_corner_id = 0 + props.is_active = True + return {"FINISHED"} + + +class FinishWallFilletPreview(bpy.types.Operator): + """Commit the previewed fillet with the tuned radius and exit preview. + + Preview state survives a failed commit so the user can re-tune without + re-selecting.""" + + bl_idname = "bim.finish_wall_fillet_preview" + bl_label = "Apply Wall Fillet" + bl_description = "Commit the rounded corner with the previewed radius" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + return preview_base.commit_preview( + self, + context, + "wall_fillet", + "create_wall_fillet", + ("wall_a_id", "wall_b_id", "radius", "editing_corner_id"), + ) + + +class CancelWallFilletPreview(bpy.types.Operator): + """Exit wall-fillet preview without committing.""" + + bl_idname = "bim.cancel_wall_fillet_preview" + bl_label = "Cancel Wall Fillet" + bl_description = "Discard the previewed fillet" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + if context.screen is None: + return {"CANCELLED"} + props = preview_base.get_preview_props(context, "wall_fillet") + if props is None or not props.is_active: + return {"CANCELLED"} + preview_base.clear_preview_state(props) + return {"FINISHED"} + + +class EnableWallFilletPreviewFromCorner(bpy.types.Operator): + """Re-open the fillet preview on an existing corner wall (pen-icon entry). + + Validate deletes and recreates the corner inside a single undo step.""" + + bl_idname = "bim.enable_wall_fillet_preview_from_corner" + bl_label = "Edit Wall Fillet" + bl_description = "Open the fillet preview for an existing rounded corner — drag radius to retune" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 1: + return False + element = tool.Ifc.get_entity(selected[0]) + return element is not None and tool.Parametric.is_fillet_corner_wall(element) + + def execute(self, context: bpy.types.Context): + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 1: + self.report({"ERROR"}, "Select exactly one fillet corner wall.") + return {"CANCELLED"} + corner_obj = selected[0] + corner_elem = tool.Ifc.get_entity(corner_obj) + if corner_elem is None or not tool.Parametric.is_fillet_corner_wall(corner_elem): + self.report({"ERROR"}, "Selection is not a fillet corner wall.") + return {"CANCELLED"} + + radius = ifcopenshell.util.element.get_pset(corner_elem, "BBIM_Wall", "FilletRadius") + if not radius: + self.report({"ERROR"}, "Corner wall has no FilletRadius pset to re-edit.") + return {"CANCELLED"} + + # The corner's own side of the rel is NOTDEFINED (see regenerate_ + # fillet_corner_wall) so neighbours don't miter against the chord axis + # — read the single rel on each side of the inverse graph rather than + # filtering on connection type. + wall_a = None + for rel in getattr(corner_elem, "ConnectedFrom", []): + if rel.is_a("IfcRelConnectsPathElements"): + wall_a = rel.RelatingElement + break + wall_b = None + for rel in getattr(corner_elem, "ConnectedTo", []): + if rel.is_a("IfcRelConnectsPathElements"): + wall_b = rel.RelatedElement + break + if wall_a is None or wall_b is None: + self.report({"ERROR"}, "Corner wall is not connected to both source walls anymore.") + return {"CANCELLED"} + + wall_a_obj = tool.Ifc.get_object(wall_a) + wall_b_obj = tool.Ifc.get_object(wall_b) + if not _walls_have_zero_slope_for_fillet(self, wall_a_obj, wall_b_obj): + return {"CANCELLED"} + + props = _wall_fillet_props(context) + if props is None: + self.report({"ERROR"}, "Wall fillet preview state is unavailable.") + return {"CANCELLED"} + if props.is_active: + bpy.ops.bim.cancel_wall_fillet_preview() + + props.wall_a_id = wall_a.id() + props.wall_b_id = wall_b.id() + props.radius = float(radius) + props.editing_corner_id = corner_elem.id() + props.is_active = True + return {"FINISHED"} + + +class CreateWallFillet(bpy.types.Operator, tool.Ifc.Operator): + """Replace the corner between two straight walls with a curved LAYER2 + corner wall (banana body, inherits layer set / height / x_angle / type + from wall A).""" + + bl_idname = "bim.create_wall_fillet" + bl_label = "Create Wall Fillet" + bl_description = "Replace the corner between two walls with a rounded corner of the given radius" + bl_options = {"REGISTER", "UNDO"} + + wall_a_id: bpy.props.IntProperty(name="Wall A (active) IFC id") + wall_b_id: bpy.props.IntProperty(name="Wall B (other) IFC id") + radius: bpy.props.FloatProperty( + name="Radius", + default=0.5, + subtype="DISTANCE", + unit="LENGTH", + description=( + "Signed radius — positive produces a convex outward fillet, " + "negative flips the arc center to the opposite side for an " + "inverted (concave inward) corner." + ), + ) + editing_corner_id: bpy.props.IntProperty( + name="Existing fillet corner IFC id", + default=0, + description=( + "Non-zero on the pen-icon re-edit flow. The operator deletes this " + "corner + its path connections before recreating with the new radius." + ), + ) + + if TYPE_CHECKING: + wall_a_id: int + wall_b_id: int + radius: float + editing_corner_id: int + + def _execute(self, context): + ifc_file = tool.Ifc.get() + if ifc_file is None: + self.report({"ERROR"}, "No IFC file loaded.") + return {"CANCELLED"} + + try: + elem_a = ifc_file.by_id(self.wall_a_id) + elem_b = ifc_file.by_id(self.wall_b_id) + except Exception: + self.report({"ERROR"}, "One of the source walls is no longer in the IFC file.") + return {"CANCELLED"} + + wall_a_obj = tool.Ifc.get_object(elem_a) + wall_b_obj = tool.Ifc.get_object(elem_b) + if wall_a_obj is None or wall_b_obj is None: + self.report({"ERROR"}, "One of the source walls has no Blender object.") + return {"CANCELLED"} + + if not _walls_have_zero_slope_for_fillet(self, wall_a_obj, wall_b_obj): + return {"CANCELLED"} + + geom = tool.Wall.compute_wall_fillet_geometry(wall_a_obj, wall_b_obj, self.radius) + if geom is None or not geom["valid"]: + reason = geom.get("reason") if geom else "unknown" + self.report({"ERROR"}, f"Fillet geometry rejected (reason: {reason}).") + return {"CANCELLED"} + if geom["wall_type_id"] is None: + self.report({"ERROR"}, "Active wall has no IfcWallType to inherit.") + return {"CANCELLED"} + + tangent_a = Vector(geom["tangent_a"]) + tangent_b = Vector(geom["tangent_b"]) + side_a = geom["wall_a_join_side"] + side_b = geom["wall_b_join_side"] + chord = tangent_b - tangent_a + chord_length = chord.length + if chord_length < 1e-6: + self.report({"ERROR"}, "Tangent points coincide — invalid fillet geometry.") + return {"CANCELLED"} + + # Pen-icon re-edit path: remove the existing fillet corner + its two + # path connections to A and B before recreating. The deletion + + # recreation runs in the same tool.Ifc.Operator transaction, so a + # single undo restores the pre-re-edit state. + if self.editing_corner_id: + try: + old_corner = ifc_file.by_id(self.editing_corner_id) + except Exception: + old_corner = None + if old_corner is not None: + for rel in list(getattr(old_corner, "ConnectedFrom", [])) + list( + getattr(old_corner, "ConnectedTo", []) + ): + if rel.is_a("IfcRelConnectsPathElements"): + bonsai.core.geometry.remove_connection(tool.Geometry, connection=rel) + old_corner_obj = tool.Ifc.get_object(old_corner) + ifcopenshell.api.root.remove_product(ifc_file, product=old_corner) + if old_corner_obj is not None: + bpy.data.objects.remove(old_corner_obj) + + # Drop any existing direct connection between A and B before + # retopologising — the corner wall will own the new connections at + # both ends. + for conn in list(elem_a.ConnectedTo) + list(elem_a.ConnectedFrom): + if not conn.is_a("IfcRelConnectsPathElements"): + continue + other = conn.RelatedElement if conn.RelatingElement == elem_a else conn.RelatingElement + if other == elem_b: + bonsai.core.geometry.remove_connection(tool.Geometry, connection=conn) + + # Shorten A and B so their corner-side endpoints sit on the tangent + # points. DumbWallJoiner.extend projects the world-space target onto + # the wall's local axis and rewrites the relevant endpoint, then + # regenerates the body so it matches the new axis. + joiner = DumbWallJoiner() + joiner.extend(wall_a_obj, tangent_a, connection=side_a) + joiner.extend(wall_b_obj, tangent_b, connection=side_b) + + # Instantiate the corner wall from A's wall type so it inherits the + # material layer set, height, x_angle, and IfcWallType. + bpy.ops.bim.add_occurrence(relating_type_id=geom["wall_type_id"]) + corner_obj = bpy.context.active_object + if corner_obj is None: + self.report({"ERROR"}, "Failed to instantiate the corner wall.") + return {"CANCELLED"} + corner_elem = tool.Ifc.get_entity(corner_obj) + if corner_elem is None: + self.report({"ERROR"}, "Corner wall has no IFC entity after creation.") + return {"CANCELLED"} + + # IfcMaterialLayerSetUsage on a wall contracts that the body is + # derived from the Axis swept along the layer-set thicknesses; + # spec-honouring importers discard an explicit body when they see a + # usage. The corner's defining geometry IS the explicit banana body, + # so neither the usage form nor the owning IfcWallType may stay + # associated. A plain IfcMaterial carries no swept-layer contract — + # the corner inherits a single material from the dominant (thickest) + # layer of wall A's effective material set for QTO / colour / + # reporting purposes without putting the explicit body at risk. + ifcopenshell.api.material.unassign_material(ifc_file, products=[corner_elem]) + ifcopenshell.api.type.unassign_type(ifc_file, related_objects=[corner_elem]) + + dominant_material = _pick_dominant_wall_material(elem_a) + if dominant_material is not None: + ifcopenshell.api.material.assign_material( + ifc_file, + products=[corner_elem], + type="IfcMaterial", + material=dominant_material, + ) + + placement = _apply_fillet_corner_geometry(ifc_file, corner_obj, geom, wall_a_obj) + if placement is None: + self.report({"ERROR"}, "Could not apply fillet corner geometry (degenerate chord or missing body context).") + return {"CANCELLED"} + _, _, _, chord_length_si = placement + + # Axis: 2-point straight chord polyline from (0,0) to (chord_length,0) + # in wall-local IFC units. The body curves while the axis stays + # straight — IFC viewers and downstream Bonsai code that read the + # reference line via get_reference_line get a usable 2-point result + # instead of partial samples off a 3-point arc. + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) + joiner.set_axis( + corner_elem, + Vector((0.0, 0.0)), + Vector((chord_length_si / unit_scale, 0.0)), + ) + + # Mark the corner wall BEFORE the downstream recalculate so + # tool.Model.recreate_wall short-circuits and preserves the curved + # geometry. The pset also gates the enable poll. FilletRadius is + # stored alongside IsFilletCorner so the corner can be rebuilt later + # (neighbour move, layer-thickness edit, pen-icon re-edit). + pset = ifcopenshell.api.pset.add_pset(ifc_file, product=corner_elem, name="BBIM_Wall") + ifcopenshell.api.pset.edit_pset( + ifc_file, + pset=pset, + properties={"IsFilletCorner": True, "FilletRadius": float(self.radius)}, + ) + + # Connect A and B to the corner with the corner's OWN side typed as + # NOTDEFINED rather than ATSTART/ATEND. regenerate_wall_representation + # .join() early-returns when either side is NOTDEFINED, so neighbour + # A's miter cut never reads the corner's chord-axis reference line. + # A and B end FLAT at tangent_a / tangent_b — which is perpendicular + # to their own axis AND to the curve's tangent direction at that + # point, so the neighbour cross-sections align exactly with the + # banana profile's cap. + ifcopenshell.api.geometry.connect_path( + ifc_file, + relating_element=elem_a, + related_element=corner_elem, + relating_connection=side_a, + related_connection="NOTDEFINED", + ) + ifcopenshell.api.geometry.connect_path( + ifc_file, + relating_element=corner_elem, + related_element=elem_b, + relating_connection="NOTDEFINED", + related_connection=side_b, + ) + + # Recalculate A and B so their miter cuts pick up the new connections + # to the corner. The corner itself is skipped by tool.Model. + # recreate_wall's IsFilletCorner gate, preserving the curved body. + tool.Model.recalculate_walls([wall_a_obj, corner_obj, wall_b_obj]) + _resync_walls_after_mutation([wall_a_obj, corner_obj, wall_b_obj]) + return {"FINISHED"} + + +def _wall_fillet_gizmo_x_matrix(location: Vector, x_direction: Vector) -> Matrix: + """4×4 matrix placing a gizmo at ``location`` with local +X aligned to + ``x_direction`` in world space.""" + 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 + + +def _wall_fillet_preview_walls(context: bpy.types.Context): + """``(wall_a_obj, wall_b_obj)`` pinned by the preview, or ``(None, None)`` + when inactive or stale.""" + props = _wall_fillet_props(context) + if props is None or not props.is_active: + return None, None + ifc_file = tool.Ifc.get() + if ifc_file is None: + return None, None + try: + elem_a = ifc_file.by_id(props.wall_a_id) + elem_b = ifc_file.by_id(props.wall_b_id) + except (RuntimeError, KeyError): + return None, None + wall_a_obj = tool.Ifc.get_object(elem_a) if elem_a else None + wall_b_obj = tool.Ifc.get_object(elem_b) if elem_b else None + return wall_a_obj, wall_b_obj + + +class GizmoWallExtendVertically(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin): + """Activates when a LAYER3 element (typically a slab) is active and a LAYER2 + wall is co-selected. Mirrors the N-panel ``Extend To Underside`` button (which + shows under the same active-LAYER3 + LAYER2-in-selection rule). Clicking + dispatches ``bim.extend_walls_to_underside``, which extends the wall up to the + active element's bottom faces. + + Anchored at the wall's local X = 0 (wall origin endpoint), wall-local Y on the + camera-facing side, and the world Z of the active object — so the icon visually + sits at the elevation the wall will reach after extending.""" + + bl_idname = "OBJECT_GGT_bim_wall_extend_vertically" + bl_label = "Wall Extend Vertically 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_topology_gizmo_poll_gate(context): + return False + selected = 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 + active_element = tool.Ifc.get_entity(active) + if not active_element or tool.Model.get_usage_type(active_element) != "LAYER3": + return False + other = next(o for o in selected if o is not active) + other_element = tool.Ifc.get_entity(other) + if not other_element or tool.Model.get_usage_type(other_element) != "LAYER2": + return False + return True + + def setup(self, context: bpy.types.Context) -> None: + default_color, highlight_color = self.get_decoration_colors() + self.extend_vertical_icon = self.setup_icon_gizmo( + "VIEW3D_GT_extend_vertical", + default_color, + highlight_color, + "bim.extend_walls_to_underside", + ) + + def position_gizmos(self, context: bpy.types.Context) -> None: + active = context.active_object + if active is None: + return + wall_obj = next((o for o in tool.Blender.get_selected_objects() if o is not active), None) + if wall_obj is None: + return + geom = _get_wall_geom_cached(self, wall_obj) + if not geom: + return + mw = wall_obj.matrix_world + icon_y = _wall_camera_facing_icon_y(context, mw, geom) + # X = 0 in wall-local, Y on the camera-facing outer side, world Z lifted to + # the active object's elevation — the height the wall is about to reach. + world_pos = mw @ Vector((0.0, icon_y, 0.0)) + world_pos.z = active.matrix_world.translation.z + billboard_rot = gizmo.get_billboard_rotation(context) + world_pos += gizmo.top_down_clearance(context, billboard_rot) + self.extend_vertical_icon.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot) + + +class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin): + """Activates when exactly two LAYER2 walls are selected. Dispatches between four + state-specific icons based on the geometric + IFC relationship of the walls: + + - **Joined** (``IfcRelConnectsPathElements`` between them): + ``unjoin_icon`` (``VIEW3D_GT_split``, outward arrows) at the shared corner. + Clicking dispatches ``bim.unjoin_walls``. + - **Collinear** (axes on the same infinite line, not joined): + ``merge_icon`` (``VIEW3D_GT_merge``, inward arrows) at the midpoint of the + closest endpoint pair. Clicking dispatches ``bim.merge_wall``. + - **Joinable corner** (non-parallel, axes meet near endpoints, not joined): + ``join_icon`` (``VIEW3D_GT_merge``) at the projected intersection on the + floor, PLUS ``extend_to_wall_icon`` (``VIEW3D_GT_extend``) at the + intersection at the active wall's Z=height. The Z difference disambiguates + "join the corner" vs "extend this wall into the other." + - **None of the above**: all icons hidden. + + Per-frame positioning via `BillboardingGizmoGroupMixin` ensures the icons + keep facing the camera as the viewport is orbited.""" + + bl_idname = "OBJECT_GGT_bim_wall_join_intersection" + bl_label = "Wall Join Intersection Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + # Hide the gizmo when walls are nearly parallel (intersection would be unreasonably far). + # cos(2°) ≈ 0.9994 → walls within ~2° of parallel are treated as parallel for this purpose. + PARALLEL_DOT_THRESHOLD = 0.9994 + # Perpendicular tolerance (m) for treating two parallel wall axes as collinear. + COLLINEAR_LINE_TOLERANCE = 0.05 + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + if not _wall_topology_gizmo_poll_gate(context): + return False + selected = tool.Blender.get_selected_objects() + if len(selected) != 2: + return False + for o in selected: + element = tool.Ifc.get_entity(o) + if not element or not tool.Parametric.is_wall(element): + return False + return True + + # Screen-space vertical offset between stacked icons in a state branch — + # camera's screen-up so the fillet icon sits visibly clear of the + # join/unjoin icon at any view angle. + ICON_STACK_OFFSET_Y: ClassVar[float] = 0.4 + + # Per-region weakref map populated in ``setup()``. The wall-join preview + # decorator dereferences this each draw to read live ``is_highlight`` + # state off the join / extend-to-wall / fillet icons in the same region + # it's currently drawing in, so the preview lines can switch to + # ``decorator_color_selected`` while the user hovers a target. + _active_instances: ClassVar["dict[int, weakref.ReferenceType[GizmoWallJoinIntersection]]"] = {} + + def setup(self, context: bpy.types.Context) -> None: + default_color, highlight_color = self.get_decoration_colors() + self.unjoin_icon = self.setup_icon_gizmo("VIEW3D_GT_split", default_color, highlight_color, "bim.unjoin_walls") + self.merge_icon = self.setup_icon_gizmo("VIEW3D_GT_merge", default_color, highlight_color, "bim.merge_wall") + # L-corner glyph reads as "join at the corner"; differentiated from + # the T glyph (extend) by where the bars meet (corner vs midline). + self.join_icon = self.setup_icon_gizmo( + "VIEW3D_GT_wall_corner", default_color, highlight_color, "bim.join_walls_intersection" + ) + # T-junction glyph reads as "extend this wall into the other's side". + self.extend_to_wall_icon = self.setup_icon_gizmo( + "VIEW3D_GT_wall_tee", default_color, highlight_color, "bim.extend_walls_to_wall" + ) + # Fillet entry — shows in the same two states (joined / intersect) + # where rounding the corner is well-defined. Click enters the preview + # flow; GizmoWallFilletPreview takes over from there. + self.fillet_icon = self.setup_icon_gizmo( + "VIEW3D_GT_fillet", default_color, highlight_color, "bim.enable_wall_fillet_preview" + ) + if context.region is not None: + type(self)._active_instances[context.region.as_pointer()] = weakref.ref(self) + + def _all_icons(self) -> tuple[bpy.types.Gizmo, ...]: + return (self.unjoin_icon, self.merge_icon, self.join_icon, self.extend_to_wall_icon, self.fillet_icon) + + def _hide_all(self) -> None: + for icon in self._all_icons(): + icon.hide = True + + def position_gizmos(self, context: bpy.types.Context) -> None: + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 2: + self._hide_all() + return + elem_a = tool.Ifc.get_entity(selected[0]) + elem_b = tool.Ifc.get_entity(selected[1]) + geom_a = _get_wall_geom_cached(self, selected[0]) + geom_b = _get_wall_geom_cached(self, selected[1]) + if elem_a is None or elem_b is None or geom_a is None or geom_b is None: + self._hide_all() + return + seg_a = _wall_axis_world_segment_from_geom(selected[0], geom_a) + seg_b = _wall_axis_world_segment_from_geom(selected[1], geom_b) + billboard_rot = gizmo.get_billboard_rotation(context) + screen_up = gizmo.get_screen_up(billboard_rot) + clearance = gizmo.top_down_clearance(context, billboard_rot) + anchor_z = self._stack_anchor_z(context, selected, geom_a, geom_b) + + # Pair predicate cache key: pair GlobalIds + world-matrix tuples for + # both walls. World matrices feed _are_walls_collinear / + # project_axis_intersection, so they belong in the key. + pair_guids = tuple(sorted((elem_a.GlobalId, elem_b.GlobalId))) + mw_a_key = tuple(map(tuple, selected[0].matrix_world)) + mw_b_key = tuple(map(tuple, selected[1].matrix_world)) + mw_key = (mw_a_key, mw_b_key) if elem_a.GlobalId <= elem_b.GlobalId else (mw_b_key, mw_a_key) + + # State 1: walls are already joined → Unjoin (bottom) + Fillet (above). + joined = _get_wall_pair_predicate_cached( + self, ("joined", pair_guids), lambda: _are_walls_joined(elem_a, elem_b) + ) + if joined: + corner = _collinear_boundary_world(seg_a, seg_b) + anchor = Vector((corner.x, corner.y, anchor_z)) + clearance + self._stack_at(anchor, screen_up, billboard_rot, (self.unjoin_icon, self.fillet_icon)) + self.merge_icon.hide = True + self.join_icon.hide = True + self.extend_to_wall_icon.hide = True + return + + # State 2: walls are collinear (parallel axes on the same line) → show Merge + # at the boundary midpoint between them. No stack; single icon at the + # geometric boundary makes the merge target unambiguous. + collinear = _get_wall_pair_predicate_cached( + self, + ("collinear", pair_guids, mw_key, self.PARALLEL_DOT_THRESHOLD, self.COLLINEAR_LINE_TOLERANCE), + lambda: _are_walls_collinear(seg_a, seg_b, self.PARALLEL_DOT_THRESHOLD, self.COLLINEAR_LINE_TOLERANCE), + ) + if collinear: + boundary = _collinear_boundary_world(seg_a, seg_b) + clearance + self.merge_icon.matrix_basis = gizmo.billboarded_at(boundary, billboard_rot) + self.merge_icon.hide = False + self.unjoin_icon.hide = True + self.join_icon.hide = True + self.extend_to_wall_icon.hide = True + self.fillet_icon.hide = True + return + + # State 3: non-parallel walls → Join (L, bottom) + Extend-to-Wall (T) + # + Fillet (top) stacked along screen-up at the wall-top anchor. + # PARALLEL_DOT_THRESHOLD (cos 2°) is the only bound that matters: + # walls within 2° of parallel produce extrusion joints that race + # toward infinity, so project_axis_intersection returns None and the + # early-return below hides the whole group. + intersection_tuple = _get_wall_pair_predicate_cached( + self, + ("intersection", pair_guids, mw_key, self.PARALLEL_DOT_THRESHOLD), + lambda: core.project_axis_intersection( + (tuple(seg_a[0]), tuple(seg_a[1])), + (tuple(seg_b[0]), tuple(seg_b[1])), + self.PARALLEL_DOT_THRESHOLD, + ), + ) + if intersection_tuple is None: + self._hide_all() + return + intersection = Vector(intersection_tuple) + anchor = Vector((intersection.x, intersection.y, anchor_z)) + clearance + self._stack_at(anchor, screen_up, billboard_rot, (self.extend_to_wall_icon, self.join_icon, self.fillet_icon)) + self.unjoin_icon.hide = True + self.merge_icon.hide = True + + def _stack_anchor_z( + self, + context: bpy.types.Context, + selected: list[bpy.types.Object], + geom_a: dict, + geom_b: dict, + ) -> float: + # Wall-top Z is the bottom of the screen-up stack — high enough that + # the icons sit on top of the wall instead of clipping into it. + # Prefer the active wall's top (the height the user is operating on); + # fall back to the taller of the two if the active object isn't one + # of the selected walls (mid-selection-transition frame). + active = context.active_object if context.active_object in selected else None + if active is selected[0]: + return active.matrix_world.translation.z + geom_a["height"] + if active is selected[1]: + return active.matrix_world.translation.z + geom_b["height"] + return max( + selected[0].matrix_world.translation.z + geom_a["height"], + selected[1].matrix_world.translation.z + geom_b["height"], + ) + + def _stack_at( + self, + anchor: Vector, + screen_up: Vector, + billboard_rot: Matrix, + icons: tuple[bpy.types.Gizmo, ...], + ) -> None: + for k, icon in enumerate(icons): + icon.matrix_basis = gizmo.billboarded_at(anchor + screen_up * (self.ICON_STACK_OFFSET_Y * k), billboard_rot) + icon.hide = False + + +class GizmoWallLinkToggle(gizmo.GizmoLinkToggle, bpy.types.Gizmo): + """Link-toggle glyph with a partner-wall highlight on hover. The owning + gizmo group writes the partner Blender object onto each icon every frame + via ``partner_obj``; on ``is_highlight`` the partner's bbox is outlined + inline so the user sees which wall the click will disconnect from + before committing. + + The partner reference is stashed on the gizmo instance rather than + read back from the bound operator handle because Blender's Gizmo API + exposes ``target_set_operator`` for binding but no symmetric getter.""" + + bl_idname = "VIEW3D_GT_wall_link_toggle" + __slots__ = ("partner_obj",) + + def setup(self) -> None: + super().setup() + self.partner_obj = None + + def draw(self, context: bpy.types.Context) -> None: + super().draw(context) + if not self.is_highlight: + return + partner = self.partner_obj + if partner is None: + return + draw_wall_partner_bbox(context, partner) + + +class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin): + """Activates when exactly one LAYER2 wall is selected. Surfaces an unjoin icon at + every join location inferred from the wall's IfcRelConnectsPathElements inverse + graph — the single-selection mirror of `GizmoWallJoinIntersection`'s two-wall + unjoin state. A wall may participate in many such rels (up to 1 ATSTART + 1 ATEND + by end, plus unlimited ATPATH T-junctions), so a pool of icons is preallocated + and hidden on a per-frame basis based on the live connection set. + + Each visible icon dispatches `bim.unjoin_wall_path_connection` with the partner + wall's GlobalId set on the bound operator properties, so a click removes only + the single rel under that icon — the other connections on the same wall survive. + + Mutually exclusive with `GizmoWallJoinIntersection` via `poll()` (that group + requires len(selected) == 2; this one requires 1).""" + + bl_idname = "OBJECT_GGT_bim_wall_unjoin_single" + bl_label = "Wall Unjoin (single selection) Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + # Pool size. ATSTART + ATEND + ATPATH connections are rarely more than a handful + # on real models; 16 is generous enough that excess is exceptional. Excess drops + # a one-time console warning. The cap exists because Blender only permits + # GizmoGroup to allocate gizmos inside setup() — draw_prepare / refresh-time + # creation is forbidden — so the pool must be sized upfront for the worst case. + POOL_SIZE = 16 + ICON_SCALE = 0.35 + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + if not _wall_topology_gizmo_poll_gate(context): + return False + active = tool.Blender.get_active_object(is_selected=True) + if active is None: + return False + selected = tool.Blender.get_selected_objects() + if len(selected) != 1: + return False + element = tool.Ifc.get_entity(active) + if not element or not tool.Parametric.is_path_connectable_wall(element): + return False + props = tool.Model.get_wall_props(active) + if not props.is_editing: + return False + return True + + def setup(self, context: bpy.types.Context) -> None: + default_color, highlight_color = self.get_decoration_colors() + # Bind the operator on each pool icon ONCE at setup time and keep the returned + # OperatorProperties handles. target_set_operator allocates a fresh handle on + # every call, so calling it from position_gizmos (which fires every redraw + # frame via draw_prepare) would discard and re-allocate ~60Hz per visible + # icon. Stashing the handles lets per-frame work be a plain property write. + self.unjoin_icons = [] + self.unjoin_op_props = [] + for _ in range(self.POOL_SIZE): + icon = self.setup_icon_gizmo( + "VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.unjoin_wall_path_connection" + ) + icon.hide = True + self.unjoin_icons.append(icon) + self.unjoin_op_props.append(icon.target_set_operator("bim.unjoin_wall_path_connection")) + + def position_gizmos(self, context: bpy.types.Context) -> None: + # Default: hide every pool slot. The visible-set is rebuilt from the live + # connection list each frame so disconnects/reconnects elsewhere in the + # session don't leave ghost icons behind. + for icon in self.unjoin_icons: + icon.hide = True + + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 1: + return + wall_obj = selected[0] + elem = tool.Ifc.get_entity(wall_obj) + geom = _get_wall_geom_cached(self, wall_obj) + if elem is None or geom is None: + return + seg_self = _wall_axis_world_segment_from_geom(wall_obj, geom) + billboard_rot = gizmo.get_billboard_rotation(context) + clearance = gizmo.top_down_clearance(context, billboard_rot) + + connections = _get_wall_connections_cached(self, elem) + if len(connections) > self.POOL_SIZE and not getattr(self, "_pool_cap_warned", False): + print( + f"[bonsai] GizmoWallUnjoinSingle: wall has {len(connections)} path connections; " + f"only the first {self.POOL_SIZE} unjoin gizmos are shown." + ) + self._pool_cap_warned = True + + for slot_idx, (other_elem, self_ct, other_ct) in enumerate(connections): + if slot_idx >= self.POOL_SIZE: + break + other_obj = tool.Ifc.get_object(other_elem) + if other_obj is None: + continue + other_geom = _get_wall_geom_cached(self, other_obj) + if other_geom is None: + continue + seg_other = _wall_axis_world_segment_from_geom(other_obj, other_geom) + location = tool.Wall.path_connection_location_world(seg_self, self_ct, seg_other, other_ct) + icon = self.unjoin_icons[slot_idx] + icon.matrix_basis = gizmo.billboarded_at(location + clearance, billboard_rot, scale=self.ICON_SCALE) + icon.hide = False + # Only the partner-GlobalId property is rewritten per frame; the operator + # binding itself is the long-lived handle set up at setup() time. GlobalId + # (not Blender object name) keeps the binding stable across renames, file + # save/reload, and any sit-in-the-undo-stack interlude between dispatch + # and execute. + self.unjoin_op_props[slot_idx].other_wall_guid = other_elem.GlobalId + # Mirror the partner reference onto the icon itself so its draw() + # can outline the partner on hover without a Gizmo-side getter on + # the bound operator (the API exposes target_set_operator with + # no symmetric reader). + icon.partner_obj = other_obj + + +class GizmoWallFilletPreview(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin): + """Gizmo group for the wall-fillet preview: radius dimension widget + + trim-length dimension widget + validate / cancel icons. + + On degenerate geometry the dimensions and validate hide but cancel stays + visible so the user always has an exit. Radius and trim widgets express + the same single DOF — both read/write the canonical `props.radius`.""" + + bl_idname = "OBJECT_GGT_bim_wall_fillet_preview" + bl_label = "Wall Fillet 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: bpy.types.Context) -> bool: + props = _wall_fillet_props(context) + 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.wall_a_id) + ifc_file.by_id(props.wall_b_id) + except (RuntimeError, KeyError): + return False + return True + + def setup(self, context: bpy.types.Context) -> None: + default_color, highlight_color = self.get_decoration_colors() + + # Lazy-fetched closures re-resolve the Scene per call so the freed-RNA + # crash on file open / undo doesn't hit the gizmo callbacks. + _props_callback = preview_base.make_props_callback("wall_fillet") + + gz = self.gizmos.new("BIM_GT_gizmo_dimension") + gz.move_get_cb = preview_base.make_dim_getter(_props_callback, "radius") + gz.move_set_cb = preview_base.make_dim_setter(_props_callback, "radius") + # Set `axis` only (NOT `local_axis`) so `get_axis_direction` falls + # through to the world-space direction set per frame on each gizmo. + # The preview spans world space independent of either wall's local + # frame, so the active-object transform that `local_axis` would go + # through is the wrong frame. + gz.axis = Vector((1, 0, 0)) + gz.invert_delta = False + gz.delta_scale = 1.0 + gz.prop_name = "Radius" + 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 + # Arrowheads at BOTH ends + extension lines make this read as a proper + # dimension annotation rather than a single-direction drag arrow. + gz.show_start_arrow = True + gz.show_end_arrow = True + gz.show_extension_lines = True + gz.text_formatter = None + self.radius_dim = gz + + # Sweep angle is geometrically invariant during drag (depends only on + # the angle between the two walls). Cached per frame so the trim + # getter / setter can convert trim_length ↔ radius via tan(sweep/2) + # without re-running the full geometry pipeline on every drag tick. + self._sweep_angle = math.pi / 2 + + # Trim-length widget expresses the SAME single DOF as the radius + # widget via the leg setback distance (intersection → tangent point). + # Architects often think "how much of each wall do I cut back" rather + # than "what radius do I want"; this widget surfaces that mental model + # without introducing a second degree of freedom. Both widgets stay + # in sync because they read/write the same canonical `radius` field. + trim_gz = self.gizmos.new("BIM_GT_gizmo_dimension") + trim_gz.move_get_cb = self._make_trim_getter() + trim_gz.move_set_cb = self._make_trim_setter() + trim_gz.axis = Vector((1, 0, 0)) + trim_gz.invert_delta = False + trim_gz.delta_scale = 1.0 + trim_gz.prop_name = "Trim Length" + trim_gz.gizmo_group = self + trim_gz.color = default_color + trim_gz.color_highlight = highlight_color + trim_gz.alpha = 1.0 + trim_gz.use_draw_modal = True + trim_gz.use_draw_scale = False + trim_gz.text_offset_sign = 1 + trim_gz.text_alignment = gizmo.TextAlignment.CENTER + trim_gz.show_start_arrow = True + trim_gz.show_end_arrow = True + trim_gz.show_extension_lines = True + trim_gz.text_formatter = None + self.trim_dim = trim_gz + + 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_wall_fillet_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_wall_fillet_preview") + + def _make_trim_getter(self): + """Closure returning |radius| * tan(sweep/2) — the live leg setback + distance — from the cached sweep angle and the canonical radius.""" + + def _get() -> float: + props = _wall_fillet_props(bpy.context) + if props is None: + return 0.0 + sweep = max(self._sweep_angle, 1e-3) + return abs(float(props.radius)) * math.tan(sweep / 2.0) + + return _get + + def _make_trim_setter(self): + """Closure writing radius from a dragged trim_length, preserving the + radius sign so a concave preview stays concave when the user drags the + trim widget. Clamps to the FloatProperty's lower bound so the gizmo + can't push radius below the geometry helper's tolerance.""" + + def _set(value: float) -> None: + props = _wall_fillet_props(bpy.context) + if props is None: + return + sweep = max(self._sweep_angle, 1e-3) + tan_half = math.tan(sweep / 2.0) + if tan_half < 1e-9: + return + sign = -1.0 if float(props.radius) < 0 else 1.0 + new_radius = sign * max(0.001, float(value)) / tan_half + props.radius = new_radius + for area in bpy.context.screen.areas if bpy.context.screen else (): + if area.type == "VIEW_3D": + area.tag_redraw() + + return _set + + def position_gizmos(self, context: bpy.types.Context) -> None: + wall_a_obj, wall_b_obj = _wall_fillet_preview_walls(context) + if wall_a_obj is None or wall_b_obj is None: + for gz in (self.radius_dim, self.trim_dim, self.validate_icon, self.cancel_icon): + gz.hide = True + return + + props = _wall_fillet_props(context) + if props is None: + for gz in (self.radius_dim, self.trim_dim, self.validate_icon, self.cancel_icon): + gz.hide = True + return + + geom = tool.Wall.compute_wall_fillet_geometry(wall_a_obj, wall_b_obj, props.radius) + billboard_rot = gizmo.get_billboard_rotation(context) + + # Geometry helper failed outright (e.g. wall A's reference line went + # missing). No anchor to draw on — hide everything. + if geom is None: + for gz in (self.radius_dim, self.trim_dim, self.validate_icon, self.cancel_icon): + gz.hide = True + return + + # Parallel / near-collinear axes — no defined arc at all. Drop radius + # + trim + validate; keep cancel visible at the would-be intersection + # so the user has an exit. The dim widgets have nowhere to anchor. + if not geom["valid"] and not geom.get("invalid_radius"): + self.radius_dim.hide = True + self.trim_dim.hide = True + self.validate_icon.hide = True + anchor = None + if geom.get("arc_center") is not None: + anchor = Vector(geom["arc_center"]) + elif geom.get("intersection") is not None: + anchor = Vector(geom["intersection"]) + if anchor is not None: + # Same screen-up lift as the valid branch so the cancel icon + # doesn't sit on top of any underlying preview lines in + # top-down view. + screen_up = gizmo.get_screen_up(billboard_rot) + self.cancel_icon.matrix_basis = gizmo.billboarded_at( + anchor + screen_up * self.ICON_Z_OFFSET, billboard_rot, scale=self.ICON_SCALE + ) + self.cancel_icon.hide = False + else: + self.cancel_icon.hide = True + return + + # Both `valid=True` and `invalid_radius=True` populate arc_center, + # apex, and tangent points. Keep the radius dim visible on overshoot + # so the user can drag back to a valid radius; hide validate so a + # commit can't surface an operator-level error. + invalid_radius = bool(geom.get("invalid_radius")) + self.radius_dim.hide = False + self.trim_dim.hide = False + self.cancel_icon.hide = False + self.validate_icon.hide = invalid_radius + + # Cache the sweep angle so the trim widget's getter / setter can + # convert without re-running the geometry pipeline. Falls back to a + # right angle if the helper somehow omits it. + self._sweep_angle = float(geom.get("sweep_angle") or math.pi / 2) + + arc = geom["arc"] + arc_center = Vector(geom["arc_center"]) + tangent_a = Vector(geom["tangent_a"]) + tangent_b = Vector(geom["tangent_b"]) + intersection = Vector(geom["intersection"]) + + # Radius dimension at the arc apex with local +X pointing INWARD + # toward the arc center. Visual line traces apex → center, matching + # the radius itself; drag in the +X direction (toward arrow tip = + # toward arc center) increases the radius. Anchored at the FLOOR of + # the wall (z=0 of the arc samples) so the gizmo reads against the + # wall geometry rather than hovering in mid-air. + apex_index = len(arc) // 2 + apex = Vector(arc[apex_index]) + inward = arc_center - apex + if inward.length > 1e-6: + inward.normalize() + self.radius_dim.matrix_basis = _wall_fillet_gizmo_x_matrix(apex, inward) + self.radius_dim.axis = inward + self.radius_dim.set_dimension_length(abs(props.radius)) + else: + self.radius_dim.hide = True + + # Trim dimension along wall A from intersection toward tangent_a; + # same DOF as the radius widget, both update `radius`. + along_a = tangent_a - intersection + tangent_offset = abs(float(props.radius)) * math.tan(self._sweep_angle / 2.0) + if along_a.length > 1e-6 and tangent_offset > 1e-6: + along_a_dir = along_a.normalized() + self.trim_dim.matrix_basis = _wall_fillet_gizmo_x_matrix(intersection, along_a_dir) + self.trim_dim.axis = along_a_dir + self.trim_dim.set_dimension_length(tangent_offset) + else: + self.trim_dim.hide = True + + # Validate / cancel anchored ABOVE the arc apex along the camera's + # screen-up direction so they're always visibly clear of the radius + # dim widget (which runs apex → arc_center). Screen-up keeps the + # offset perpendicular to the view plane at any angle — world +Z + # would collapse to zero on-screen in top-down view and plant the + # icons on top of the radius arrowhead. + screen_up = gizmo.get_screen_up(billboard_rot) + anchor = apex + screen_up * 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) + + +class GizmoWallFilletReedit(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin): + """Pen-icon re-edit gizmo for an existing fillet corner wall. + + Mutually exclusive with an active preview and with GizmoWallEdition.""" + + bl_idname = "OBJECT_GGT_bim_wall_fillet_reedit" + bl_label = "Wall Fillet Re-edit Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + ICON_TOP_LIFT: ClassVar[float] = 0.15 + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + if not _wall_topology_gizmo_poll_gate(context): + return False + active = tool.Blender.get_active_object(is_selected=True) + if active is None: + return False + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 1: + return False + element = tool.Ifc.get_entity(active) + if element is None or not element.is_a("IfcWall"): + return False + # IsFilletCorner pset is the authoritative signal — the re-edit + # operator separately verifies both neighbour connections exist and + # reports a user-facing error if either side has been disconnected + # since creation. Validating that here would hide the pen icon + # silently, leaving the user with no obvious next step. + return tool.Parametric.is_fillet_corner_wall(element) + + def setup(self, context: bpy.types.Context) -> None: + default_color, highlight_color = self.get_decoration_colors() + self.edit_icon = self.setup_icon_gizmo( + "VIEW3D_GT_pen", + default_color, + highlight_color, + "bim.enable_wall_fillet_preview_from_corner", + ) + + def position_gizmos(self, context: bpy.types.Context) -> None: + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 1: + self.edit_icon.hide = True + return + corner_obj = selected[0] + geom = _get_wall_geom_cached(self, corner_obj) + if geom is None: + self.edit_icon.hide = True + return + billboard_rot = gizmo.get_billboard_rotation(context) + origin = corner_obj.matrix_world.translation + top_z = origin.z + (geom.get("height") or 3.0) + self.ICON_TOP_LIFT + anchor = Vector((origin.x, origin.y, top_z)) + gizmo.top_down_clearance(context, billboard_rot) + self.edit_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot) + self.edit_icon.hide = False + + +class GizmoWallFilletToggleOpenings(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin): + """Surfaces the show / hide openings icon on a fillet-corner wall. + + GizmoWallEdition's idle row already exposes this toggle for LAYER2 walls, + but its poll routes through the parametric edit pipeline which by IFC + spec rejects fillet corners (their banana body is hand-built and would be + flattened by the parametric regen). The openings toggle itself is a + viewport-state action independent of the body, so a parallel poll keeps + it available without re-opening the parametric edits.""" + + bl_idname = "OBJECT_GGT_bim_wall_fillet_toggle_openings" + bl_label = "Fillet Wall Toggle Openings Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + ICON_TOP_LIFT: ClassVar[float] = 0.15 + # Screen-space X offset from the pen icon so the two stack horizontally + # rather than overlap at the chord midpoint. + ICON_OFFSET_X: ClassVar[float] = 0.4 + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + if not _wall_topology_gizmo_poll_gate(context): + return False + active = tool.Blender.get_active_object(is_selected=True) + if active is None: + return False + if len(list(tool.Blender.get_selected_objects())) != 1: + return False + element = tool.Ifc.get_entity(active) + if element is None or not element.is_a("IfcWall"): + return False + return tool.Parametric.is_fillet_corner_wall(element) + + 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: + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 1: + self.toggle_openings_icon.hide = True + return + corner_obj = selected[0] + geom = _get_wall_geom_cached(self, corner_obj) + if geom is None: + self.toggle_openings_icon.hide = True + return + billboard_rot = gizmo.get_billboard_rotation(context) + origin = corner_obj.matrix_world.translation + top_z = origin.z + (geom.get("height") or 3.0) + self.ICON_TOP_LIFT + anchor = Vector((origin.x, origin.y, top_z)) + gizmo.top_down_clearance(context, billboard_rot) + offset_x = billboard_rot @ Vector((self.ICON_OFFSET_X, 0.0, 0.0)) + self.toggle_openings_icon.matrix_basis = gizmo.billboarded_at(anchor + offset_x, billboard_rot) + self.toggle_openings_icon.hide = False + + +class JoinWallsIntersection(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.join_walls_intersection" + bl_label = "Join Walls at Corner" + bl_description = "Join two walls at their corner" + 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 + if _poll_reject_array_children(cls): + return False + return True + + def _perform(self, context: bpy.types.Context) -> set[str]: + try: + core.join_walls_LV(tool.Ifc, tool.Blender, tool.Geometry, DumbWallJoiner(), tool.Model) + except core.RequireTwoWallsError as e: + self.report({"ERROR"}, str(e)) + return {"CANCELLED"} + _resync_walls_after_mutation(tool.Blender.get_selected_objects()) + return {"FINISHED"} + + +def draw_wall_partner_bbox( + context: bpy.types.Context, + partner_obj: bpy.types.Object, +) -> None: + """Paint a wireframe bbox around ``partner_obj`` in the same 3D pass. + Called inline from gizmo ``draw()`` methods so the highlight tracks the + hover cursor one-for-one — no POST_VIEW handler, no timing lag. + + Silently no-ops if the object has no bounding box (e.g. Empties).""" + segments = bbox_world_edges(partner_obj) + if not segments: + return + prefs = tool.Blender.get_addon_preferences() + color = prefs.decorator_color_special[:3] + draw_polyline_segments( + context, + segments, + color, + _BBOX_HIGHLIGHT_LINE_ALPHA, + _BBOX_HIGHLIGHT_LINE_WIDTH, + ) + + +class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator): + """Hover-gated preview lines that visualise where a click-to-act wall + gizmo's operator would move the wall geometry. Four state machines: + + - **Join intersection** — when exactly two non-joined, non-collinear, + non-parallel LAYER2 walls are selected, draws one line from each wall's + nearest axis endpoint to the projected XY intersection. Each line stays + at its own wall's axis Z (so for walls on different storeys the lines + stay horizontal at their own floor levels). Mirrors the visibility of + the Join + Extend-to-Wall icons in ``GizmoWallJoinIntersection``. + - **Extend to cursor** — when a single LAYER2 wall is selected and the + ``extend`` wall-gizmo pref is enabled, draws one line from the wall's + nearer axis endpoint to the 3D cursor's projected X on the wall axis. + Mirrors the visibility of the ``extend_x_gizmo`` icon in + ``GizmoWallEdition``. + - **Extend Z to cursor** — one preview line at the cursor's projected X + from wall base to the cursor's Z, visualising the new total height. + Hover-gated on ``extend_z_gizmo``. + - **Split at cursor** — one world-vertical line at the cursor's projected X + from wall base to wall top, visualising the cut plane. Hover-gated on + ``split_gizmo``. + + Purely a visual cue — hidden by the same gizmo-preferences toggle as the + icons themselves.""" + + draw_method = "draw_lines" + + LINE_WIDTH = 1.5 + LINE_ALPHA = 0.8 + QUAD_ALPHA = 0.45 + + def draw_lines(self, context: bpy.types.Context) -> None: + if not tool.Blender.are_viewport_gizmos_enabled(): + return + prefs = tool.Blender.get_addon_preferences() + self._draw_join_preview(context, prefs) + self._draw_cursor_extend_preview(context, prefs) + self._draw_cursor_extend_z_preview(context, prefs) + self._draw_cursor_split_preview(context, prefs) + self._draw_cursor_perpendicular_wall_preview(context, prefs) + + def _stroke( + self, + context: bpy.types.Context, + segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]], + color_rgb: tuple[float, float, float], + ) -> None: + draw_polyline_segments(context, segments, color_rgb, self.LINE_ALPHA, self.LINE_WIDTH) + + def _fill( + self, + context: bpy.types.Context, + quads: list[ + tuple[ + tuple[float, float, float], + tuple[float, float, float], + tuple[float, float, float], + tuple[float, float, float], + ] + ], + color_rgb: tuple[float, float, float], + ) -> None: + _fill_quads_alpha(context, quads, color_rgb, self.QUAD_ALPHA) + + @staticmethod + def _wall_floor_quad(mw: Matrix, x0: float, x1: float, y0: float, y1: float) -> tuple[ + tuple[float, float, float], + tuple[float, float, float], + tuple[float, float, float], + tuple[float, float, float], + ]: + """4 world-space corners of a Z=0 wall-local rectangle, CCW when + viewed from +Z. Used for top-down floor-projection quads so the + extend / split previews stay legible from plan view.""" + return ( + tuple(mw @ Vector((x0, y0, 0.0))), + tuple(mw @ Vector((x1, y0, 0.0))), + tuple(mw @ Vector((x1, y1, 0.0))), + tuple(mw @ Vector((x0, y1, 0.0))), + ) + + def _draw_join_preview(self, context: bpy.types.Context, prefs: Any) -> None: + """Render four preview lines per wall pair — two at each wall's base + Z, two at each wall's top Z — extending each axis to the projected + intersection. Two lines per wall (base + top) communicate the full + plane that the join/extend operator would weld at, not just the + floor edge. + + Hover colour: + - **Join or Fillet hover** → all four lines light up (both walls + converge at the corner; fillet is a symmetric round of the same + corner). + - **Extend-to-Wall hover** → only the base+top of the non-active + wall (the wall the default-direction operator would extend). + - Otherwise → ``decorations_colour``.""" + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 2: + return + elem_a = tool.Ifc.get_entity(selected[0]) + elem_b = tool.Ifc.get_entity(selected[1]) + if elem_a is None or elem_b is None: + return + if not tool.Parametric.is_path_connectable_wall(elem_a) or not tool.Parametric.is_path_connectable_wall(elem_b): + return + + geom_a = tool.Wall.read_geometry(selected[0]) + geom_b = tool.Wall.read_geometry(selected[1]) + if geom_a is None or geom_b is None: + return + seg_a = _wall_axis_world_segment_from_geom(selected[0], geom_a) + seg_b = _wall_axis_world_segment_from_geom(selected[1], geom_b) + parallel_threshold = core.PARALLEL_DOT_THRESHOLD + collinear_tolerance = core.COLLINEAR_LINE_TOLERANCE + state, intersection_tuple = _classify_wall_join_state( + elem_a, elem_b, seg_a, seg_b, parallel_threshold, collinear_tolerance + ) + if state != "intersect": + return + assert intersection_tuple is not None + floor_lines = core.wall_join_preview_lines( + (tuple(seg_a[0]), tuple(seg_a[1])), + (tuple(seg_b[0]), tuple(seg_b[1])), + intersection_tuple, + ) + height_a = geom_a.get("height", 0.0) + height_b = geom_b.get("height", 0.0) + wall_a_floor, wall_b_floor = floor_lines + + def _lift(seg: tuple, dz: float) -> tuple: + (sx, sy, sz), (ex, ey, ez) = seg + return ((sx, sy, sz + dz), (ex, ey, ez + dz)) + + wall_a_top = _lift(wall_a_floor, height_a) + wall_b_top = _lift(wall_b_floor, height_b) + join_hovered, extend_hovered, fillet_hovered = self._join_group_hover_state(GizmoWallJoinIntersection, context) + default = tuple(prefs.decorations_colour[:3]) + selected_rgb = tuple(prefs.decorator_color_selected[:3]) + all_lines = [wall_a_floor, wall_a_top, wall_b_floor, wall_b_top] + + if join_hovered or fillet_hovered: + self._stroke(context, all_lines, selected_rgb) + return + + if extend_hovered: + extended_idx = self._extended_wall_index(context, selected) + if extended_idx is not None: + extended_lines = [wall_a_floor, wall_a_top] if extended_idx == 0 else [wall_b_floor, wall_b_top] + untouched_lines = [wall_b_floor, wall_b_top] if extended_idx == 0 else [wall_a_floor, wall_a_top] + self._stroke(context, untouched_lines, default) + self._stroke(context, extended_lines, selected_rgb) + return + + self._stroke(context, all_lines, default) + + @staticmethod + def _extended_wall_index(context: bpy.types.Context, selected: list[bpy.types.Object]) -> Optional[int]: + """Index of the non-active wall in ``selected``, or ``None``.""" + active = context.active_object + if active is selected[0]: + return 1 + if active is selected[1]: + return 0 + return None + + def _join_group_hover_state(self, gizmo_cls: type, context: bpy.types.Context) -> tuple[bool, bool, bool]: + """Return ``(join_hovered, extend_to_wall_hovered, fillet_hovered)`` + from the ``GizmoWallJoinIntersection`` instance in **the same region** + the decorator is currently drawing in. Returns ``(False, False, + False)`` when that region has no live gizmo group (poll → False, + weakref cleared, or no setup yet). Read-only; any access exception + is swallowed so a transient bpy-state hiccup never breaks the draw + loop.""" + inst = self._lookup_active_instance(gizmo_cls, context) + if inst is None: + return False, False, False + try: + return ( + bool(inst.join_icon.is_highlight), + bool(inst.extend_to_wall_icon.is_highlight), + bool(inst.fillet_icon.is_highlight), + ) + except (AttributeError, ReferenceError): + return False, False, False + + def _active_layer2_wall_for_gizmo_preview( + self, context: bpy.types.Context, prefs: Any + ) -> Optional[bpy.types.Object]: + """Active object iff it is the sole selected object, is a LAYER2 IfcWall, + and the wall feature's gizmo prefs are enabled. Otherwise ``None``. + Shared guard for every cursor-anchored extend-preview path so each one + short-circuits on the same conditions the gizmo group itself uses.""" + gizmo_prefs = getattr(prefs.gizmos, "wall", None) + if gizmo_prefs is None or not getattr(gizmo_prefs, "enabled", True): + return None + active = context.active_object + if active is None: + return None + selected = list(tool.Blender.get_selected_objects()) + if active not in selected or len(selected) != 1: + return None + element = tool.Ifc.get_entity(active) + if element is None or not tool.Parametric.is_wall(element): + return None + if tool.Model.get_usage_type(element) != "LAYER2": + return None + return active + + def _draw_cursor_extend_preview(self, context: bpy.types.Context, prefs: Any) -> None: + """Hover-gated floor-plane preview for the extend-X icon. Quads sit + on the Z=0 plane spanning the wall's ``offset`` to + ``offset + thickness`` Y band so the operator's effect reads from + plan view without side-view clutter: + + - **Cursor outside ``[anchor_x, anchor_x+length]`` (grow)**: one + green ``decorator_color_selected`` quad over the extension + (nearer endpoint → cursor X). + - **Cursor inside the wall extent (shrink)**: green quad for the + portion that REMAINS (cursor X → farther endpoint) + red + ``decorator_color_error`` quad for the portion the operator + REMOVES (nearer endpoint → cursor X).""" + active = self._active_layer2_wall_for_gizmo_preview(context, prefs) + if active is None: + return + if not self._cursor_icon_hovered(GizmoWallEdition, "extend_x_gizmo", context): + return + geom = tool.Wall.read_geometry(active) + if geom is None: + return + anchor_x = geom.get("anchor_x", 0.0) + length = geom.get("length", 0.0) + offset = geom.get("offset", 0.0) + thickness = geom.get("thickness", 0.0) + if length <= 0 or thickness <= 0: + return + mw = active.matrix_world + cursor_local = mw.inverted() @ context.scene.cursor.location + y_floor_0 = offset + y_floor_1 = offset + thickness + start_x = anchor_x + end_x = anchor_x + length + keep_color = tuple(prefs.decorator_color_selected[:3]) + nearest_x = start_x if abs(cursor_local.x - start_x) < abs(cursor_local.x - end_x) else end_x + + def emit(x0: float, x1: float, color: tuple[float, float, float]) -> None: + if abs(x1 - x0) < 1e-6: + return + lo, hi = (x0, x1) if x0 < x1 else (x1, x0) + self._fill(context, [self._wall_floor_quad(mw, lo, hi, y_floor_0, y_floor_1)], color) + + if start_x < cursor_local.x < end_x: + remove_color = tuple(prefs.decorator_color_error[:3]) + farthest_x = end_x if nearest_x == start_x else start_x + emit(nearest_x, cursor_local.x, remove_color) + emit(cursor_local.x, farthest_x, keep_color) + return + emit(nearest_x, cursor_local.x, keep_color) + + def _draw_cursor_split_preview(self, context: bpy.types.Context, prefs: Any) -> None: + """Render two red lines at the cursor's projected X: one vertical along + the wall's local Z (visible in elevation views), one horizontal across + the wall's thickness band at floor Z (visible in plan / top-down view). + Together they trace the cut plane the split operator would commit. + Hover-gated on the split icon; coloured with the destructive-action + warning red to match the icon's own hover signal.""" + active = self._active_layer2_wall_for_gizmo_preview(context, prefs) + if active is None: + return + if not self._cursor_icon_hovered(GizmoWallEdition, "split_gizmo", context): + return + geom = tool.Wall.read_geometry(active) + if geom is None: + return + anchor_x = geom.get("anchor_x", 0.0) + length = geom.get("length", 0.0) + height = geom.get("height", 0.0) + offset = geom.get("offset", 0.0) + thickness = geom.get("thickness", 0.0) + if length <= 0 or height <= 0: + return + mw = active.matrix_world + cursor_local = mw.inverted() @ context.scene.cursor.location + if not (anchor_x < cursor_local.x < anchor_x + length): + return + color = tuple(prefs.decorator_color_error[:3]) + bottom_world = mw @ Vector((cursor_local.x, 0.0, 0.0)) + top_world = mw @ Vector((cursor_local.x, 0.0, height)) + segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [ + (tuple(bottom_world), tuple(top_world)) + ] + if thickness > 0: + base_a = mw @ Vector((cursor_local.x, offset, 0.0)) + base_b = mw @ Vector((cursor_local.x, offset + thickness, 0.0)) + segments.append((tuple(base_a), tuple(base_b))) + self._stroke(context, segments, color) + + def _draw_cursor_extend_z_preview(self, context: bpy.types.Context, prefs: Any) -> None: + """Hover-gated vertical-line preview for the extend-Z icon at the + cursor's projected X on the wall axis (y=0 reference-line plane). + + Two cases by cursor Z relative to the wall's current height: + + - **Cursor Z above the wall top (grow)**: one green + ``decorator_color_selected`` segment from z=height to z=cursor.z + (the new vertical material). + - **Cursor Z inside ``(0, height)`` (shrink)**: two segments — + green from z=0 to z=cursor.z (the portion that REMAINS), red + ``decorator_color_error`` from z=cursor.z to z=height (the + portion the operator REMOVES).""" + active = self._active_layer2_wall_for_gizmo_preview(context, prefs) + if active is None: + return + if not self._cursor_icon_hovered(GizmoWallEdition, "extend_z_gizmo", context): + return + geom = tool.Wall.read_geometry(active) + if geom is None: + return + length = geom.get("length", 0.0) + height = geom.get("height", 0.0) + if length <= 0 or height <= 0: + return + mw = active.matrix_world + cursor_local = mw.inverted() @ context.scene.cursor.location + if cursor_local.z <= 0: + return + if abs(cursor_local.z - height) < 1e-6: + return + keep_color = tuple(prefs.decorator_color_selected[:3]) + cursor_x = cursor_local.x + + def stroke(z0: float, z1: float, color: tuple[float, float, float]) -> None: + a = mw @ Vector((cursor_x, 0.0, z0)) + b = mw @ Vector((cursor_x, 0.0, z1)) + self._stroke(context, [(tuple(a), tuple(b))], color) + + if cursor_local.z > height: + stroke(height, cursor_local.z, keep_color) + return + remove_color = tuple(prefs.decorator_color_error[:3]) + stroke(0.0, cursor_local.z, keep_color) + stroke(cursor_local.z, height, remove_color) + + def _draw_cursor_perpendicular_wall_preview(self, context: bpy.types.Context, prefs: Any) -> None: + """Hover-gated floor-plane preview of the branch wall's footprint. + Green quad on Z=0 spanning the new wall's perpendicular body band + (``offset`` to ``offset + thickness`` mapped through the perpendicular + rotation) and its length from the projection on the source wall axis + to the cursor.""" + active = self._active_layer2_wall_for_gizmo_preview(context, prefs) + if active is None: + return + if not self._cursor_icon_hovered(GizmoWallEdition, "add_perpendicular_wall_gizmo", context): + return + geom = tool.Wall.read_geometry(active) + if geom is None: + return + anchor_x = geom.get("anchor_x", 0.0) + length = geom.get("length", 0.0) + offset = geom.get("offset", 0.0) + thickness = geom.get("thickness", 0.0) + if length <= 0 or thickness <= 0: + return + mw = active.matrix_world + cursor_local = mw.inverted() @ context.scene.cursor.location + params = _perpendicular_wall_params(cursor_local.x, cursor_local.y, anchor_x, length) + if params is None: + return + clamped_x, _length, side_sign = params + # New wall axis sits at source-local X = clamped_x; its body extends + # perpendicular to that axis. After rotating the new wall's ±Y body + # band into the source's local frame, the band lands at source-local + # X = clamped_x − side_sign · {offset, offset+thickness}. + x_a = clamped_x - side_sign * offset + x_b = clamped_x - side_sign * (offset + thickness) + x_lo, x_hi = (x_a, x_b) if x_a < x_b else (x_b, x_a) + y_lo, y_hi = (0.0, cursor_local.y) if cursor_local.y > 0 else (cursor_local.y, 0.0) + keep_color = tuple(prefs.decorator_color_selected[:3]) + self._fill(context, [self._wall_floor_quad(mw, x_lo, x_hi, y_lo, y_hi)], keep_color) diff --git a/src/bonsai/bonsai/bim/module/model/wall_offset_gizmos.py b/src/bonsai/bonsai/bim/module/model/wall_offset_gizmos.py new file mode 100644 index 0000000000..093a24b0d3 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/model/wall_offset_gizmos.py @@ -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 . +# +# 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), + ), +] diff --git a/src/bonsai/bonsai/bim/module/model/window.py b/src/bonsai/bonsai/bim/module/model/window.py index 30e8d767b5..5b470fc8e5 100644 --- a/src/bonsai/bonsai/bim/module/model/window.py +++ b/src/bonsai/bonsai/bim/module/model/window.py @@ -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. diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py index 828fc2c21f..00d8876f4d 100644 --- a/src/bonsai/bonsai/bim/module/model/workspace.py +++ b/src/bonsai/bonsai/bim/module/model/workspace.py @@ -841,7 +841,7 @@ class EditObjectUI: row = cls.layout.row(align=True) row.separator() row.label(text="Operations") if ui_context != "TOOL_HEADER" else row - cls.draw_regen_operations(row) + cls.draw_regen_operations(row, ui_context) if AuthoringData.data["active_material_usage"] == "LAYER2": row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row @@ -962,20 +962,14 @@ class EditObjectUI: return row @classmethod - def draw_regen_operations(cls, row): - custom_icon = custom_icon_previews.get("REGEN", custom_icon_previews["IFC"]).icon_id - + def draw_regen_operations(cls, row, ui_context): if AuthoringData.data["is_regenable_element"]: - op = row.operator("bim.hotkey", text="", icon_value=custom_icon) - description = "Recalculate Element Geometry\nHotkey: S G" - op.hotkey = "S_G" - op.description = description.strip() + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + add_layout_hotkey_operator(row, "Regen", "S_G", "Recalculate Element Geometry", ui_context) if PortData.data["total_ports"] > 0: - op = row.operator("bim.hotkey", text="", icon_value=custom_icon) - description = f"{bpy.ops.bim.regenerate_distribution_element.__doc__}\n\nHotkey: S G" - op.hotkey = "S_G" - op.description = description.strip() + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + add_layout_hotkey_operator(row, "Regen", "S_G", bpy.ops.bim.regenerate_distribution_element.__doc__, ui_context) @classmethod def draw_void(cls, context, row): @@ -1300,9 +1294,15 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): bpy.ops.bim.generate_space() return if self.active_material_usage == "LAYER2": - bpy.ops.bim.recalculate_wall() + if element and tool.Model.has_underside_connection(element): + bpy.ops.bim.regenerate_wall_to_underside() + else: + bpy.ops.bim.recalculate_wall() elif self.active_material_usage == "LAYER3": bpy.ops.bim.recalculate_slab() + wall_objs = tool.Model.get_connected_wall_objs(element) + if wall_objs: + core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, wall_objs) elif tool.System.get_ports(element): bpy.ops.bim.regenerate_distribution_element() elif self.active_material_usage == "PROFILE": @@ -1442,10 +1442,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: diff --git a/src/bonsai/bonsai/bim/module/project/__init__.py b/src/bonsai/bonsai/bim/module/project/__init__.py index e83da49c5b..7243bd51b9 100644 --- a/src/bonsai/bonsai/bim/module/project/__init__.py +++ b/src/bonsai/bonsai/bim/module/project/__init__.py @@ -28,6 +28,10 @@ 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_opening_cuts, + operator.BIM_OT_select_pending_opening_cuts, operator.BIM_OT_load_clipping_planes, operator.BIM_OT_save_clipping_planes, operator.ChangeLibraryElement, @@ -82,6 +86,7 @@ classes = ( prop.FilterCategory, prop.Link, prop.EditedObj, + prop.PendingOpeningRecut, prop.BIMProjectProperties, prop.MeasureToolSettings, ui.BIM_MT_new_project, diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index e2aa06337e..2684fe3a4e 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -15,6 +15,8 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# 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 @@ -1220,6 +1223,19 @@ 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.", + ) + tool.Project.load_default_thumbnails() tool.Project.set_default_context() tool.Project.set_default_modeling_dimensions() @@ -1902,11 +1918,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`. @@ -1932,6 +1948,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") @@ -2000,7 +2030,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}") @@ -2010,7 +2040,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() @@ -3403,3 +3433,108 @@ 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"} diff --git a/src/bonsai/bonsai/bim/module/project/prop.py b/src/bonsai/bonsai/bim/module/project/prop.py index 8f7aed8283..93a32f0ba5 100644 --- a/src/bonsai/bonsai/bim/module/project/prop.py +++ b/src/bonsai/bonsai/bim/module/project/prop.py @@ -295,6 +295,17 @@ 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 BIMProjectProperties(PropertyGroup): is_editing: BoolProperty(name="Is Editing", default=False) is_loading: BoolProperty(name="Is Loading", default=False) @@ -352,6 +363,7 @@ 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) style_limit: IntProperty( name="Style Limit", default=300, @@ -516,6 +528,7 @@ class BIMProjectProperties(PropertyGroup): deflection_tolerance: float angular_tolerance: float void_limit: int + pending_opening_recut: bpy.types.bpy_prop_collection_idprop[PendingOpeningRecut] style_limit: int distance_limit: float false_origin_mode: Literal["AUTOMATIC", "MANUAL", "DISABLED"] diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py index b8b8e1a4e1..c2ff5cb957 100644 --- a/src/bonsai/bonsai/bim/module/project/ui.py +++ b/src/bonsai/bonsai/bim/module/project/ui.py @@ -28,8 +28,9 @@ from bpy.types import Menu, Panel, UIList import bonsai.bim import bonsai.tool as tool from bonsai.bim.helper import draw_attributes, prop_with_search -from bonsai.bim.ifc import IfcStore +from bonsai.bim.ifc import IfcStore, is_cache_locked_by_other_process from bonsai.bim.module.project.data import LinksData, ProjectData +from bonsai.bim.ui import draw_multiline_text if TYPE_CHECKING: from bonsai.bim.module.project.prop import ( @@ -166,6 +167,20 @@ class BIM_PT_project(Panel): if pprops.is_loading: self.draw_advanced_loading_ui(context) elif self.file or props.ifc_file: + if is_cache_locked_by_other_process(): + box = self.layout.box() + box.alert = True + row = box.row(align=True) + row.label(text="IFC Already Open in Another Blender Instance", icon="ERROR") + row.operator("bim.dismiss_multi_instance_warning", text="", icon="CANCEL") + draw_multiline_text( + box.column(align=True), + "This file is open in another Blender instance. Editing the same " + "IFC from two instances at once can lose your work or display " + "outdated geometry. Close the other Blender instances to continue safely.", + context=context, + ) + if props.has_blend_warning: box = self.layout.box() box.alert = True @@ -175,6 +190,21 @@ class BIM_PT_project(Panel): op.uri = "https://docs.bonsaibim.org/guides/troubleshooting.html#saving-and-loading-blend-files" row.operator("bim.close_blend_warning", text="", icon="CANCEL") + if pending := pprops.pending_opening_recut: + box = self.layout.box() + box.alert = True + box.label(text="Opening Cuts Skipped", icon="ERROR") + draw_multiline_text( + box.column(align=True), + f"{len(pending)} element(s) had too many openings to cut during load. " + f"Apply to recompute their meshes, or dismiss to leave them as they are.", + context=context, + ) + row = box.row(align=True) + row.operator("bim.select_pending_opening_cuts", text="Select Elements", icon="RESTRICT_SELECT_OFF") + row.operator("bim.apply_pending_opening_cuts", text="Apply Openings", icon="PLAY") + row.operator("bim.dismiss_pending_opening_cuts", text="", icon="CANCEL") + if props.ifc_file: self.draw_loaded_project_ui(context) else: diff --git a/src/bonsai/bonsai/bim/module/spatial/operator.py b/src/bonsai/bonsai/bim/module/spatial/operator.py index cd6bc6cab2..7279b3b6c4 100644 --- a/src/bonsai/bonsai/bim/module/spatial/operator.py +++ b/src/bonsai/bonsai/bim/module/spatial/operator.py @@ -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"} diff --git a/src/bonsai/bonsai/bim/module/style/prop.py b/src/bonsai/bonsai/bim/module/style/prop.py index bd62021d65..a4cfb2cb9e 100644 --- a/src/bonsai/bonsai/bim/module/style/prop.py +++ b/src/bonsai/bonsai/bim/module/style/prop.py @@ -118,6 +118,19 @@ def update_shader_graph(self: Union["Texture", "BIMStylesProperties"], context: tool.Loader.create_surface_style_with_textures(material, shading_data, textures_data) +def _make_clear_null_updater(null_prop: str): + def _update(self: "BIMStylesProperties", context: bpy.types.Context) -> None: + self[null_prop] = False + update_shader_graph(self, context) + + return _update + + +update_diffuse_colour = _make_clear_null_updater("is_diffuse_colour_null") +update_specular_colour = _make_clear_null_updater("is_specular_colour_null") +update_specular_highlight_value = _make_clear_null_updater("is_specular_highlight_null") + + UV_MODES = [ ("UV", "UV", _("Actual UV data presented on the geometry")), ("Generated", "Generated", _("Automatically-generated UV from the vertex positions of the mesh")), @@ -221,24 +234,29 @@ class BIMStylesProperties(PropertyGroup): transparency: bpy.props.FloatProperty( name="Transparency", default=0.0, min=0.0, max=1.0, update=update_shader_graph ) - # TODO: do something on null? - is_diffuse_colour_null: BoolProperty(name="Is Null") + is_diffuse_colour_null: BoolProperty(name="Is Null", update=update_shader_graph) diffuse_colour_class: EnumProperty( items=[(x, x, "") for x in get_args(ColourClass)], name="Diffuse Colour Class", - update=update_shader_graph, + update=update_diffuse_colour, ) diffuse_colour: bpy.props.FloatVectorProperty( - name="Diffuse Colour", subtype="COLOR", default=(1, 1, 1), min=0.0, max=1.0, size=3, update=update_shader_graph + name="Diffuse Colour", + subtype="COLOR", + default=(1, 1, 1), + min=0.0, + max=1.0, + size=3, + update=update_diffuse_colour, ) diffuse_colour_ratio: bpy.props.FloatProperty( - name="Diffuse Ratio", default=0.0, min=0.0, max=1.0, update=update_shader_graph + name="Diffuse Ratio", default=0.0, min=0.0, max=1.0, update=update_diffuse_colour ) - is_specular_colour_null: BoolProperty(name="Is Null") + is_specular_colour_null: BoolProperty(name="Is Null", update=update_shader_graph) specular_colour_class: EnumProperty( items=[(x, x, "") for x in get_args(ColourClass)], name="Specular Colour Class", - update=update_shader_graph, + update=update_specular_colour, default="IfcNormalisedRatioMeasure", ) specular_colour: bpy.props.FloatVectorProperty( @@ -248,7 +266,7 @@ class BIMStylesProperties(PropertyGroup): min=0.0, max=1.0, size=3, - update=update_shader_graph, + update=update_specular_colour, ) specular_colour_ratio: bpy.props.FloatProperty( name="Specular Ratio", @@ -256,16 +274,16 @@ class BIMStylesProperties(PropertyGroup): default=0.0, min=0.0, max=1.0, - update=update_shader_graph, + update=update_specular_colour, ) - is_specular_highlight_null: BoolProperty(name="Is Null") + is_specular_highlight_null: BoolProperty(name="Is Null", update=update_shader_graph) specular_highlight: bpy.props.FloatProperty( name="Specular Highlight", description="Used as Roughness value in PHYSICAL Reflectance Method", default=0.0, min=0.0, max=1.0, - update=update_shader_graph, + update=update_specular_highlight_value, ) reflectance_method: EnumProperty( name="Reflectance Method", diff --git a/src/bonsai/bonsai/bim/module/system/decorator.py b/src/bonsai/bonsai/bim/module/system/decorator.py index 13ac7519f1..4c3e810ceb 100644 --- a/src/bonsai/bonsai/bim/module/system/decorator.py +++ b/src/bonsai/bonsai/bim/module/system/decorator.py @@ -15,9 +15,10 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# This file was modified with the assistance of an AI coding tool. -import bmesh import bpy import gpu from bpy.app.handlers import persistent @@ -78,15 +79,9 @@ class SystemDecorator: batch.draw(shader) def draw_faces(self, bm, vertices_coords): - """mutates original bm (triangulates it) - so the triangulation edges will be shown too - """ - traingulated_bm = bm - bmesh.ops.triangulate(traingulated_bm, faces=traingulated_bm.faces) - - face_indices = [[v.index for v in f.verts] for f in traingulated_bm.faces] + """Submit a non-mutating beauty-triangulated TRIS batch over ``bm``'s faces.""" faces_color = transparent_color(self.addon_prefs.decorator_color_special) - self.draw_batch("TRIS", vertices_coords, faces_color, face_indices) + tool.Blender.draw_bmesh_face_tris(bm, vertices_coords, faces_color, self.draw_batch) def __call__(self, context, get_custom_bmesh=None, draw_faces=False, exit_edit_mode_callback=None): self.addon_prefs = tool.Blender.get_addon_preferences() diff --git a/src/bonsai/bonsai/bim/module/system/operator.py b/src/bonsai/bonsai/bim/module/system/operator.py index 5fe47564c0..8ff18f4704 100644 --- a/src/bonsai/bonsai/bim/module/system/operator.py +++ b/src/bonsai/bonsai/bim/module/system/operator.py @@ -317,13 +317,23 @@ class MEPConnectElements(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Connect MEP Elements" bl_description = "Connects two selected elements by their closest located ports and adjusts them" bl_options = {"REGISTER", "UNDO"} - obj1_name: bpy.props.StringProperty(name="Object 1") - obj2_name: bpy.props.StringProperty(name="Object 2") + obj1_guid: bpy.props.StringProperty(name="Object 1 GlobalId") + obj2_guid: bpy.props.StringProperty(name="Object 2 GlobalId") def _execute(self, context): - if self.obj1_name and self.obj2_name: - obj1 = bpy.data.objects.get(self.obj1_name) - obj2 = bpy.data.objects.get(self.obj2_name) + if self.obj1_guid and self.obj2_guid: + ifc_file = tool.Ifc.get() + try: + el1_lookup = ifc_file.by_guid(self.obj1_guid) + el2_lookup = ifc_file.by_guid(self.obj2_guid) + except RuntimeError: + self.report({"ERROR"}, "Could not resolve MEP elements from supplied GlobalIds.") + return {"CANCELLED"} + obj1 = tool.Ifc.get_object(el1_lookup) + obj2 = tool.Ifc.get_object(el2_lookup) + if not obj1 or not obj2: + self.report({"ERROR"}, "Supplied MEP elements have no Blender object bound.") + return {"CANCELLED"} else: if not context.selected_objects or len(context.selected_objects) != 2: self.report({"ERROR"}, "Need to select 2 objects.") diff --git a/src/bonsai/bonsai/bim/module/type/ui.py b/src/bonsai/bonsai/bim/module/type/ui.py index fbd1edd446..1848858953 100644 --- a/src/bonsai/bonsai/bim/module/type/ui.py +++ b/src/bonsai/bonsai/bim/module/type/ui.py @@ -151,7 +151,8 @@ class BIM_PT_type_attributes(Panel): row = layout.row(align=True) row.label(text=attribute["name"]) value = get_display_value(attribute["value"]) - row.label(text=value) + op = row.operator("bim.select_similar", text=value, icon="NONE", emboss=False) + op.key = "type." + attribute["name"] def add_object_button(self, context): diff --git a/src/bonsai/bonsai/bim/module/void/operator.py b/src/bonsai/bonsai/bim/module/void/operator.py index c27b8891f6..819e172723 100644 --- a/src/bonsai/bonsai/bim/module/void/operator.py +++ b/src/bonsai/bonsai/bim/module/void/operator.py @@ -36,9 +36,17 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator): bl_description = ( "Apply opening objects to an Element.\n\n" "The Element and the openings to be applied should be selected. The order of selection is not important.\n" - "Opening can be just a Blender mesh object." + "Opening can be just a Blender mesh object.\n\n" + "Shift+click: keep the filling at its current matrix_world — skip the wall-axis snap " + "and the rl1/rl2 Z-elevation default that the regular click applies." ) + # Toggled by ``invoke`` when the user holds SHIFT during a gizmo / hotkey + # click. The filling-opening generator gates its snap-to-wall-axis block + # on this flag. HIDDEN + SKIP_SAVE so the flag doesn't surface in the F6 + # redo panel or persist into saved keymaps. + preserve_placement: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"}) + @classmethod def poll(cls, context): if len(context.selected_objects) < 2: @@ -46,6 +54,10 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator): return False return True + def invoke(self, context, event): + self.preserve_placement = bool(event.shift) + return self.execute(context) + def _execute(self, context): selected_objects = context.selected_objects target_object = selected_objects[0] @@ -68,7 +80,12 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator): elif not element1.is_a("IfcOpeningElement") and not element2.is_a("IfcOpeningElement"): if element1.is_a("IfcWindow") or element1.is_a("IfcDoor"): # Add a fill to an element. obj1, obj2 = obj2, obj1 - FilledOpeningGenerator().generate(obj2, obj1, target=obj2.matrix_world.translation) + FilledOpeningGenerator().generate( + obj2, + obj1, + target=obj2.matrix_world.translation, + preserve_placement=self.preserve_placement, + ) continue elif element1.is_a("IfcOpeningElement") or element2.is_a("IfcOpeningElement"): if element1.is_a("IfcOpeningElement"): # Reassign an opening to another element. diff --git a/src/bonsai/bonsai/bim/parametric_lifecycle.py b/src/bonsai/bonsai/bim/parametric_lifecycle.py new file mode 100644 index 0000000000..015a183377 --- /dev/null +++ b/src/bonsai/bonsai/bim/parametric_lifecycle.py @@ -0,0 +1,656 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Shared operator mixins for parametric-edit operators. + +Edit-lifecycle mixins (Enable / Finish / Cancel): + `FeatureModifierEditMixin` — door, window (BBIM_ pset; nested + lining/panel properties; Finish + Cancel route through + ``ifcopenshell.api.feature``). + `PathPreservingEditMixin` — railing, roof (path_data preserved across + edit; only general kwargs are user-editable). + +Pattern selection (which approach a new feature should adopt): + Every parametric edit lifecycle commits to one of three patterns. Pick by + answering "does the feature share the Enable→Finish→Cancel shape that + one of the existing mixins already encodes?": + + A. Inherit one of the shared mixins below and route through + `tool.Parametric.build_edit_lifecycle`: + + - `FeatureModifierEditMixin` when the feature stores its pset as + `{general fields} + {lining_properties: {...}} + {panel_properties: {...}}` + and Finish must call a per-type `update__modifier_representation`. + + - `PathPreservingEditMixin` when the feature's pset carries a + `path_data` field that survives general-kwarg edits untouched, with + a separate Enable/Finish/Cancel lifecycle for path editing itself. + + B. Write a per-feature mixin that subclasses `ParametricEditMixinBase` + and provides `_enable_targets` / `_finish_targets` / `_cancel_targets`, + then route through `build_edit_lifecycle`. Pick this when the + feature's pset roundtrip or representation handling diverges from the + shared mixins but the Enable→Finish→Cancel shape still fits. + + C. Declare standalone Enable/Finish/Cancel Operator subclasses (no + factory) when the feature's parameter-change logic is sufficiently + unique that even a per-feature mixin would force optional hooks or + dead branches. Such operators MUST call the matrix_world drift + helpers (`tool.Geometry.commit_placement_if_moved` on Enable/Finish, + `tool.Geometry.restore_or_rebaseline_placement` on Cancel) — the + drift contract is enforced uniformly regardless of which pattern the + operators adopt. + + The authoritative list of registered parametric types — and which use + `build_edit_lifecycle` vs. standalone operators — lives in + `tool/parametric.py`'s `EDIT_TYPES` and is enforced by the registry + contract tests. + +This module hosts operator-side mixins that import ``bonsai.tool`` freely. +The lightweight parametric registry consumed at addon-enable time must stay +free of such imports and lives separately in ``tool/parametric.py``.""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from typing import ClassVar, get_args + +import bpy +import ifcopenshell.util.element +from bpy.app.handlers import persistent +from ifcopenshell import entity_instance + +import bonsai.core.geometry +import bonsai.tool as tool + + +class ParametricEditMixinBase: + """Common scaffolding for parametric edit-lifecycle mixins. + + Each per-type subclass provides four hooks: + + ``pset_name``: BBIM_ pset identifier + ``_is_element_type(element)``: IFC element predicate + ``_get_props(obj)``: PropertyGroup accessor + ``_iter_targets(context)``: list of objects to act on (default: ``[active_object]``) + + Drift handling is built in: pre-edit matrix_world drift commits to IFC on + Enable, in-edit drag commits on Finish, and Cancel restores the committed + IFC placement. This prevents an uncommitted drag from disappearing on + Finish or snapping back on Cancel. + + Operator subclasses call one of ``_enable_targets`` / ``_finish_targets`` / + ``_cancel_targets`` from their ``_execute`` method.""" + + pset_name: ClassVar[str] + + @classmethod + def _iter_targets(cls, context: bpy.types.Context) -> list[bpy.types.Object]: + obj = context.active_object + return [obj] if obj else [] + + @classmethod + def _is_element_type(cls, element: entity_instance) -> bool: + raise NotImplementedError + + @classmethod + def _get_props(cls, obj: bpy.types.Object): + raise NotImplementedError + + @classmethod + def _resolve(cls, obj: bpy.types.Object): + """Look up ``(element, props)`` for ``obj`` if it matches this type, else None. + + Common predicate guard for every lifecycle method — collapses the + ``element = tool.Ifc.get_entity(obj); assert element; if not is_(element): return`` + triplet into one call.""" + element = tool.Ifc.get_entity(obj) + if not element or not cls._is_element_type(element): + return None + return element, cls._get_props(obj) + + @classmethod + def _handle_drift_on_enable(cls, obj: bpy.types.Object) -> None: + tool.Geometry.commit_placement_if_moved(obj, apply_scale=False) + + @classmethod + def _handle_drift_on_finish(cls, obj: bpy.types.Object) -> None: + tool.Geometry.commit_placement_if_moved(obj) + + @classmethod + def _handle_drift_on_cancel(cls, obj: bpy.types.Object, element: entity_instance) -> None: + tool.Geometry.restore_or_rebaseline_placement(obj, element) + + @classmethod + def _mark_type_thumbnail_dirty(cls, element: entity_instance) -> None: + """Mark the element's type's preview thumbnail for refresh so the + property-panel preview reflects post-edit geometry. No-op for + occurrences without a backing type.""" + element_type = ifcopenshell.util.element.get_type(element) + if element_type: + tool.Model.mark_thumbnail_for_update(element_type) + + +class FeatureModifierEditMixin(ParametricEditMixinBase): + """Lifecycle for door- and window-style parametric modifier operators. + + Enable: + Read BBIM_ pset JSON → unwrap ``lining_properties`` and + ``panel_properties`` → merge constituents data → set draft props → + ``is_editing = True``. + + Finish: + Gather ``general / lining / panel`` kwargs (project units) → nest → + ``is_editing = False`` → call ``_update_modifier_representation`` → + mark thumbnail → write back to BBIM_ pset via + ``ifcopenshell.api.pset.edit_pset``. + + Cancel: + Read BBIM_ pset JSON → unwrap → restore draft props → + ``switch_representation`` to the Body representation → + ``is_editing = False``.""" + + @classmethod + def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: + """Hook: call the per-type ``update__modifier_representation``.""" + raise NotImplementedError + + @classmethod + def _enable_one(cls, obj: bpy.types.Object) -> None: + resolved = cls._resolve(obj) + if resolved is None: + return + element, props = resolved + cls._handle_drift_on_enable(obj) + data = json.loads(ifcopenshell.util.element.get_pset(element, cls.pset_name, "Data")) + data.update(data.pop("lining_properties")) + data.update(data.pop("panel_properties")) + data.update(tool.Model.get_constituents_props_data(element)) + # required since the pset can be loaded from .ifc and the PropertyGroup + # would otherwise still hold its default values + props.set_props_kwargs_from_ifc_data(data) + props.is_editing = True + + @classmethod + def _finish_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: + resolved = cls._resolve(obj) + if resolved is None: + return + element, props = resolved + data = props.get_general_kwargs(convert_to_project_units=True) + data["lining_properties"] = props.get_lining_kwargs(convert_to_project_units=True) + data["panel_properties"] = props.get_panel_kwargs(convert_to_project_units=True) + cls._update_modifier_representation(obj, context) + cls._mark_type_thumbnail_dirty(element) + tool.Pset.write_bbim_data(element, cls.pset_name, data) + cls._handle_drift_on_finish(obj) + # Set only on success: if any IFC op above raised, the user's draft survives for retry. + props.is_editing = False + + @classmethod + def _cancel_one(cls, obj: bpy.types.Object) -> None: + resolved = cls._resolve(obj) + if resolved is None: + return + element, props = resolved + # Cancel must always clear is_editing — leaving it True after a + # restore-failure would block the user from re-entering edit mode and + # the next save's stale-flag heal would silently roll back the + # cancellation. Wrap the restore in try/finally so the flag flips + # even on partial failure. + try: + data = json.loads(ifcopenshell.util.element.get_pset(element, cls.pset_name, "Data")) + data.update(data.pop("lining_properties")) + data.update(data.pop("panel_properties")) + props.set_props_kwargs_from_ifc_data(data) + body = tool.Geometry.get_body_representation(element) + bonsai.core.geometry.switch_representation(tool.Ifc, tool.Geometry, obj=obj, representation=body) + cls._handle_drift_on_cancel(obj, element) + finally: + props.is_editing = False + + def _enable_targets(self, context: bpy.types.Context) -> set[str]: + for obj in self._iter_targets(context): + self._enable_one(obj) + return {"FINISHED"} + + def _finish_targets(self, context: bpy.types.Context) -> set[str]: + for obj in self._iter_targets(context): + self._finish_one(obj, context) + return {"FINISHED"} + + def _cancel_targets(self, context: bpy.types.Context) -> set[str]: + for obj in self._iter_targets(context): + self._cancel_one(obj) + return {"FINISHED"} + + +class PathPreservingEditMixin(ParametricEditMixinBase): + """Lifecycle for railing- and roof-style parametric modifier operators. + + Distinctive: ``path_data`` is part of the BBIM_ pset but is **not** + user-editable through this lifecycle — it survives the edit untouched, only + general kwargs are diffed. (Path editing has its own separate operator + pair, ``Enable/Finish/CancelEditingPath``, out of scope here.) + + Enable: + Fetch pset data via ``tool.Model.get_modeling_bbim_pset_data`` → set + draft props → ``is_editing = True``. The subclass post-load hook + can reshape the dict to fit the PropertyGroup's storage layout + (e.g., pre-serialise a structured pset value to JSON for a + ``StringProperty`` field). + + Finish: + Read fresh pset → keep ``path_data`` → gather ``general`` kwargs + (project units) → reassemble → ``is_editing = False`` → call + ``_update_pset`` (per-type pset writer) → call ``_update_modifier_ifc_data`` + (per-type geometry commit). + + Cancel: + Read fresh pset → restore draft props → call + ``_restore_viewport_after_cancel`` (per-type viewport restore — typically + rebuilds the bmesh preview, but subclasses may load a different + representation entirely) → ``is_editing = False``.""" + + @classmethod + def _post_load_data(cls, data: dict) -> dict: + """Hook: optionally transform the pset data dict after loading and before + passing to ``set_props_kwargs_from_ifc_data``. Default: pass-through. + + Override when the PropertyGroup stores a structured pset field as a + serialised primitive — e.g., a list/dict value mapped onto a + ``StringProperty`` requires JSON-encoding here.""" + return data + + @classmethod + def _update_pset(cls, element: entity_instance, data: dict) -> None: + """Hook: per-type pset writer (``update_bbim__pset``).""" + raise NotImplementedError + + @classmethod + def _update_modifier_ifc_data(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: + """Hook: per-type ``update__modifier_ifc_data`` — commits the + modified geometry to IFC. Signature accepts ``(obj, context)`` so + subclasses can forward either argument to their existing helper.""" + raise NotImplementedError + + @classmethod + def _restore_viewport_after_cancel(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: + """Hook: restore the viewport mesh to match the just-restored draft props. + + Most subclasses rebuild a bmesh preview from props. Subclasses whose + committed IFC representation diverges from the preview may switch + the mesh back to the committed representation instead.""" + raise NotImplementedError + + @classmethod + def _enable_one(cls, obj: bpy.types.Object) -> None: + resolved = cls._resolve(obj) + if resolved is None: + return + _element, props = resolved + cls._handle_drift_on_enable(obj) + data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)["data_dict"] + data = cls._post_load_data(data) + props.set_props_kwargs_from_ifc_data(data) + props.is_editing = True + + @classmethod + def _finish_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: + resolved = cls._resolve(obj) + if resolved is None: + return + element, props = resolved + pset_data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name) + stored = pset_data["data_dict"] + data = props.get_general_kwargs(convert_to_project_units=True) + data["path_data"] = stored["path_data"] + # Skip the pset commit when the draft is identical to the stored pset: + # an Enable → Finish-without-changes cycle should not pollute the + # representation list or burn an undo entry. Drift commit still runs + # unconditionally — matrix_world drift is independent of pset content. + if data != stored: + cls._update_pset(element, data) + cls._update_modifier_ifc_data(obj, context) + cls._mark_type_thumbnail_dirty(element) + cls._handle_drift_on_finish(obj) + # Set only on success: if any IFC op above raised, the user's draft survives for retry. + props.is_editing = False + + @classmethod + def _cancel_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: + resolved = cls._resolve(obj) + if resolved is None: + return + element, props = resolved + try: + pset_data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name) + stored = pset_data["data_dict"] + draft = props.get_general_kwargs(convert_to_project_units=True) + draft["path_data"] = stored["path_data"] + nothing_changed = draft == stored + data = cls._post_load_data(stored) + props.set_props_kwargs_from_ifc_data(data) + # Skip the viewport rebuild on a no-op cancel: the mesh on screen is + # still the committed representation, and the per-type viewport-restore + # hook may be expensive (some subclasses reload a high-poly IFC + # representation rather than rebuild a preview mesh). + if not nothing_changed: + cls._restore_viewport_after_cancel(obj, context) + cls._handle_drift_on_cancel(obj, element) + finally: + # Always clear the flag — see ``FeatureModifierEditMixin._cancel_one`` + # for the rationale. + props.is_editing = False + + def _enable_targets(self, context: bpy.types.Context) -> set[str]: + for obj in self._iter_targets(context): + self._enable_one(obj) + return {"FINISHED"} + + def _finish_targets(self, context: bpy.types.Context) -> set[str]: + for obj in self._iter_targets(context): + self._finish_one(obj, context) + return {"FINISHED"} + + def _cancel_targets(self, context: bpy.types.Context) -> set[str]: + for obj in self._iter_targets(context): + self._cancel_one(obj, context) + return {"FINISHED"} + + +# --- Type-selection mixins (Cycle / Pick) ------------------------------------ + + +class TypeAccessorBase: + """Shared contract for operators that resolve and write a Literal type + attribute on a Bonsai PropertyGroup. + + Subclasses define ``element_checker``, ``props_getter``, ``type_literal``, + ``type_attr``; ``skip_element_check`` bypasses element validation. Concrete + subclasses (``CycleTypeMixin``, ``PickTypeMixin``) add the interaction + shape on top. + + Test doubles must be set on the operator instance — the predicates are + bound at class-definition time, so patching the underlying tool module + has no effect.""" + + element_checker: Callable[[entity_instance], bool] + props_getter: Callable[[bpy.types.Object], bpy.types.PropertyGroup] + type_literal: type + type_attr: str + skip_element_check: bool = False + + def _resolve_target(self, context: bpy.types.Context) -> bpy.types.Object | None: + """Return the active object iff it passes ``element_checker`` (or the + check is skipped). ``None`` signals the operator should bail with + ``{'CANCELLED'}``.""" + obj = context.active_object + if not obj: + return None + if not self.skip_element_check: + element = tool.Ifc.get_entity(obj) + if not element or not self.element_checker(element): + return None + return obj + + +class CycleTypeMixin(TypeAccessorBase): + """Operator mixin that cycles through ``type_literal``'s values. + + Shift-click reverses direction.""" + + reverse: bpy.props.BoolProperty(name="Reverse", default=False, options={"HIDDEN", "SKIP_SAVE"}) + + def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: + self.reverse = event.shift + return self.execute(context) + + def _cycle_type(self, context: bpy.types.Context) -> set[str]: + obj = self._resolve_target(context) + if obj is None: + return {"CANCELLED"} + + props = self.props_getter(obj) + types = get_args(self.type_literal) + current = getattr(props, self.type_attr) + idx = types.index(current) if current in types else 0 + direction = -1 if self.reverse else 1 + setattr(props, self.type_attr, types[(idx + direction) % len(types)]) + + return {"FINISHED"} + + +class PickTypeMixin(TypeAccessorBase): + """Operator mixin that opens a popup menu listing ``type_literal``'s values. + + Empty ``value`` ⇒ ``invoke`` opens the popup; non-empty ⇒ the user picked + an item and ``_pick_type`` applies it. + + When invoked mid-click (e.g. from a gizmo's ``target_set_operator``), the + menu opens only after the originating ``LEFTMOUSE`` releases. Otherwise + the still-pressed click flows straight into Blender's drag-through-pick + gesture and the menu commits whichever item the cursor drifts over on + release. Other invocation paths (command-palette / F3, EXEC_DEFAULT, F6 + redo) bypass the wait and open the menu immediately. + + The ``value`` StringProperty is declared on this mixin but registered via + the concrete Operator subclass's MRO scan — do not instantiate the mixin + standalone.""" + + # Carries the picked value through invoke→execute; empty default + # distinguishes "open popup" from "apply". + value: bpy.props.StringProperty(default="", options={"HIDDEN", "SKIP_SAVE"}) + + def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: + """Open the picker menu, or apply a value that was preset by a + menu-item click. + + Routing through ``execute()`` keeps subclass IFC-transaction wrapping + in the loop and means F6 redo / ``EXEC_DEFAULT`` reach the apply path.""" + if self.value: + return self.execute(context) + + if self._resolve_target(context) is None: + return {"CANCELLED"} + + if event.value == "PRESS": + context.window_manager.modal_handler_add(self) + return {"RUNNING_MODAL"} + return self._open_picker(context) + + def modal(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: + if event.type == "LEFTMOUSE" and event.value == "RELEASE": + self._open_picker(context) + # INTERFACE does not remove a modal handler; only FINISHED / + # CANCELLED do. + return {"CANCELLED"} + if event.type in {"RIGHTMOUSE", "ESC"}: + return {"CANCELLED"} + return {"RUNNING_MODAL"} + + def _open_picker(self, context: bpy.types.Context) -> set[str]: + bl_idname = self.bl_idname + values = list(get_args(self.type_literal)) + + def draw(menu_self, _menu_context): + layout = menu_self.layout + for v in values: + op = layout.operator(bl_idname, text=v) + op.value = v + + context.window_manager.popup_menu(draw, title=self.bl_label, icon="MENU_PANEL") + # The type change is a two-step interaction: this invocation just OPENS + # the menu (no state change yet); a SECOND invocation fires when the + # user clicks a menu item — that one writes ``props.`` and + # returns FINISHED. By returning INTERFACE here (and not FINISHED), the + # menu-open step is excluded from Blender's undo stack so the user + # gets exactly ONE undo entry per type change. If we returned FINISHED + # here too, the stack would gain a no-op "opened the menu" entry that + # Ctrl+Z would dismiss before reverting the actual type change — + # confusing UX where the first Ctrl+Z appears to do nothing. + return {"INTERFACE"} + + def _pick_type(self, context: bpy.types.Context) -> set[str]: + if not self.value: + # No-op rather than re-open the menu, so command-palette misuse + # doesn't infinite-loop. + return {"CANCELLED"} + + obj = self._resolve_target(context) + if obj is None: + return {"CANCELLED"} + + if self.value not in get_args(self.type_literal): + self.report({"WARNING"}, f"Unknown {self.type_attr}: {self.value!r}") + return {"CANCELLED"} + + props = self.props_getter(obj) + setattr(props, self.type_attr, self.value) + return {"FINISHED"} + + +class IntegerInputDialogMixin: + """Operator mixin that mirrors a per-feature ``IntProperty`` on the + operator into a draft attribute on the active object's parametric props, + via Blender's ``invoke_props_dialog`` popup. + + Subclasses declare: + + - ``attr_name`` — name of the IntProperty on the subclass AND of the + attribute on the resolved props (same name on both sides). + - ``props_getter`` — ``staticmethod(tool.Model.get__props)``. + - ``requires_editing`` — True iff the operator must no-op outside an + active edit lifecycle. Default False. + - ``value_min`` — minimum value to clamp to. Default 1.""" + + attr_name: ClassVar[str] = "" + props_getter: ClassVar[Callable[[bpy.types.Object], bpy.types.PropertyGroup]] + requires_editing: ClassVar[bool] = False + value_min: ClassVar[int] = 1 + + def _resolve_props(self, context: bpy.types.Context) -> bpy.types.PropertyGroup | None: + """Return the active object's parametric props if the operator is + allowed to fire, ``None`` otherwise (caller bails with ``CANCELLED``).""" + obj = context.active_object + if not obj: + return None + props = self.props_getter(obj) + if self.requires_editing and not props.is_editing: + return None + return props + + def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: # noqa: ARG002 + props = self._resolve_props(context) + if props is None: + return {"CANCELLED"} + setattr(self, self.attr_name, max(self.value_min, getattr(props, self.attr_name))) + return context.window_manager.invoke_props_dialog(self) + + def execute(self, context: bpy.types.Context) -> set[str]: + props = self._resolve_props(context) + if props is None: + return {"CANCELLED"} + setattr(props, self.attr_name, max(self.value_min, getattr(self, self.attr_name))) + return {"FINISHED"} + + +# --- Undo-resync registry ---------------------------------------------------- +# +# Per-type regenerators called from ``resync_parametric_drafts_after_undo`` +# (wired into ``bim/handler.py:undo_post`` and ``redo_post``) so the preview +# mesh of an in-progress parametric draft repaints after Ctrl+Z / Ctrl+Shift+Z. +# +# Each regenerator is a one-line lazy-import + call. Lazy imports because +# ``bonsai.bim.parametric_lifecycle`` loads before ``bim/module/model/*`` +# at addon enable; a module-level import would cycle. Each function-local +# import lands at first call, after the feature module has registered. +# +# Types with no entry — door, window, railing, etc. — are IFC-derived: undo +# of an IFC mutation already restores the entity, and ``switch_representation`` +# repaints the mesh as a side effect of the next refresh. They don't need a +# bespoke preview regenerator. + + +def _wall_undo_regenerator(obj: bpy.types.Object) -> None: + from bonsai.bim.module.model.wall import regenerate_wall_mesh_from_props + + regenerate_wall_mesh_from_props(obj) + + +def _stair_undo_regenerator(obj: bpy.types.Object) -> None: + from bonsai.bim.module.model.stair import regenerate_stair_mesh + + regenerate_stair_mesh(obj) + + +def _roof_undo_regenerator(obj: bpy.types.Object) -> None: + from bonsai.bim.module.model.roof import update_roof_modifier_bmesh + + update_roof_modifier_bmesh(obj) + + +UNDO_REGENERATORS: dict[str, Callable[[bpy.types.Object], None]] = { + "wall": _wall_undo_regenerator, + "stair": _stair_undo_regenerator, + "roof": _roof_undo_regenerator, +} + + +def resync_parametric_drafts_after_undo() -> None: + """Re-render preview meshes for every parametric draft currently active. + + Walks all objects, skips any not in a registered parametric edit, + dispatches to the per-type regenerator in ``UNDO_REGENERATORS``. A type + without an entry is left alone — its preview is either already correct + (IFC-derived) or has no draft preview mesh.""" + for obj in bpy.data.objects: + feature = tool.Parametric.is_object_editing(obj) + if feature is None: + continue + regenerator = UNDO_REGENERATORS.get(feature.name) + if regenerator is None: + continue + regenerator(obj) + tool.Blender.update_all_viewports() + + +@persistent +def _resync_on_undo(scene: bpy.types.Scene) -> None: + resync_parametric_drafts_after_undo() + + +def install_parametric_lifecycle_handlers() -> None: + """Append the undo-resync callback to undo_post and redo_post; idempotent. + + Caller must invoke this AFTER appending the central undo/redo handlers so + regenerators see restored IFC state — bpy.app.handlers fire in append order.""" + for hook in (bpy.app.handlers.undo_post, bpy.app.handlers.redo_post): + if _resync_on_undo not in hook: + hook.append(_resync_on_undo) + + +def uninstall_parametric_lifecycle_handlers() -> None: + for hook in (bpy.app.handlers.undo_post, bpy.app.handlers.redo_post): + try: + hook.remove(_resync_on_undo) + except ValueError: + pass diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index ee82e55331..5797e05425 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -15,6 +15,8 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# This file was modified with the assistance of an AI coding tool. import os import platform @@ -273,130 +275,37 @@ class BIM_UL_panel_visibilities(bpy.types.UIList): row.prop(item, "is_bookmarked", text="", icon="SOLO_ON" if item.is_bookmarked else "SOLO_OFF", emboss=False) -class GizmoPreferencesDoor(bpy.types.PropertyGroup): - """Property group for door gizmo visibility settings.""" - - overall_height: BoolProperty(name="Overall Height", default=True) - overall_width: BoolProperty(name="Overall Width", default=True) - threshold_thickness: BoolProperty(name="Threshold Thickness", default=True) - threshold_depth: BoolProperty(name="Threshold Depth", default=True) - threshold_offset: BoolProperty(name="Threshold Offset", default=True) - lining_offset: BoolProperty(name="Lining Offset", default=True) - lining_depth: BoolProperty(name="Lining Depth", default=True) - lining_thickness: BoolProperty(name="Lining Thickness", default=True) - transom_offset: BoolProperty(name="Transom Offset", default=True) - transom_thickness: BoolProperty(name="Transom Thickness", default=True) - casing_thickness: BoolProperty(name="Casing Thickness", default=True) - casing_depth: BoolProperty(name="Casing Depth", default=True) - swing_arc: BoolProperty(name="Swing Arc", default=True, description="Show door swing direction arc") - flip_arc: BoolProperty(name="Flip Arc", default=True, description="Show flip door orientation arc") - - if TYPE_CHECKING: - overall_height: bool - overall_width: bool - threshold_thickness: bool - threshold_depth: bool - threshold_offset: bool - lining_offset: bool - lining_depth: bool - lining_thickness: bool - transom_offset: bool - transom_thickness: bool - casing_thickness: bool - casing_depth: bool - swing_arc: bool - flip_arc: bool - - -class GizmoPreferencesWindow(bpy.types.PropertyGroup): - """Property group for window gizmo visibility settings.""" - - overall_height: BoolProperty(name="Overall Height", default=True) - overall_width: BoolProperty(name="Overall Width", default=True) - lining_offset: BoolProperty(name="Lining Offset", default=True) - lining_depth: BoolProperty(name="Lining Depth", default=True) - lining_thickness: BoolProperty(name="Lining Thickness", default=True) - lining_to_panel_offset_x: BoolProperty(name="Lining to Panel Offset X", default=True) - lining_to_panel_offset_y: BoolProperty(name="Lining to Panel Offset Y", default=True) - frame_depth: BoolProperty(name="Frame Depth", default=True) - frame_thickness: BoolProperty(name="Frame Thickness", default=True) - mullion_thickness: BoolProperty(name="Mullion Thickness", default=True) - first_mullion_offset: BoolProperty(name="First Mullion Offset", default=True) - second_mullion_offset: BoolProperty(name="Second Mullion Offset", default=True) - transom_thickness: BoolProperty(name="Transom Thickness", default=True) - first_transom_offset: BoolProperty(name="First Transom Offset", default=True) - second_transom_offset: BoolProperty(name="Second Transom Offset", default=True) - - if TYPE_CHECKING: - overall_height: bool - overall_width: bool - lining_offset: bool - lining_depth: bool - lining_thickness: bool - lining_to_panel_offset_x: bool - lining_to_panel_offset_y: bool - frame_depth: bool - frame_thickness: bool - mullion_thickness: bool - first_mullion_offset: bool - second_mullion_offset: bool - transom_thickness: bool - first_transom_offset: bool - second_transom_offset: bool - - -class GizmoPreferencesStair(bpy.types.PropertyGroup): - """Property group for stair gizmo visibility settings.""" - - width: BoolProperty(name="Width", default=True) - height: BoolProperty(name="Height", default=True) - tread_run: BoolProperty(name="Tread Run", default=True) - tread_depth: BoolProperty(name="Tread Depth", default=True) - riser_height: BoolProperty(name="Riser Height", default=True) - nosing_length: BoolProperty(name="Nosing Length", default=True) - nosing_depth: BoolProperty(name="Nosing Depth", default=True) - total_length_target: BoolProperty(name="Total Length Target", default=True) - base_slab_depth: BoolProperty(name="Base Slab Depth", default=True) - top_slab_depth: BoolProperty(name="Top Slab Depth", default=True) - lock: BoolProperty(name="Total Length Lock", default=True) - plus: BoolProperty(name="Add Tread (+)", default=True) - minus: BoolProperty(name="Remove Tread (-)", default=True) - cycle: BoolProperty(name="Cycle Stair Type", default=True) - - if TYPE_CHECKING: - width: bool - height: bool - tread_run: bool - tread_depth: bool - riser_height: bool - nosing_length: bool - nosing_depth: bool - total_length_target: bool - base_slab_depth: bool - top_slab_depth: bool - lock: bool - plus: bool - minus: bool - cycle: bool - - class GizmoPreferences(bpy.types.PropertyGroup): - """Property group for all gizmo visibility settings.""" + """Aggregator for parametric gizmo visibility settings. One flat bool per + parametric feature; controls whether that feature's gizmo group polls + visible in the viewport.""" draw_gizmos_in_3d_viewport: BoolProperty( name="Draw Gizmos In 3D Viewport", default=True, description="Show interactive gizmos in the 3D viewport for parametric elements", ) - door: bpy.props.PointerProperty(type=GizmoPreferencesDoor) - window: bpy.props.PointerProperty(type=GizmoPreferencesWindow) - stair: bpy.props.PointerProperty(type=GizmoPreferencesStair) + door: BoolProperty(name="Door", default=True) + window: BoolProperty(name="Window", default=True) + stair: BoolProperty(name="Stair", default=True) + railing: BoolProperty(name="Railing", default=True) + roof: BoolProperty(name="Roof", default=True) + array: BoolProperty(name="Array", default=True) + pipe_segment: BoolProperty(name="Pipe Segment", default=True) + duct_segment: BoolProperty(name="Duct Segment", default=True) + wall: BoolProperty(name="Wall", default=True) if TYPE_CHECKING: draw_gizmos_in_3d_viewport: bool - door: GizmoPreferencesDoor - window: GizmoPreferencesWindow - stair: GizmoPreferencesStair + door: bool + window: bool + stair: bool + railing: bool + roof: bool + array: bool + pipe_segment: bool + duct_segment: bool + wall: bool class DocPreferences(bpy.types.PropertyGroup): @@ -839,54 +748,13 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): ) def draw_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: + """Render one enabled-toggle per parametric feature.""" layout.label(text="Toggle visibility of gizmos in editing mode") box = layout.box() - bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Door", self.draw_door_gizmo_parameters) - bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Window", self.draw_window_gizmo_parameters) - bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Stair", self.draw_stair_gizmo_parameters) - - def draw_door_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: - from bonsai.bim.module.model.door import GizmoDoorEdition - - door_gizmos = self.gizmos.door - gizmo_prop_names = {p.attr_name for p in GizmoDoorEdition.dimension_gizmo_props} - # Add special gizmos not in dimension_gizmo_props - gizmo_prop_names.update(("swing_arc", "flip_arc")) - try: - annotations = door_gizmos.__annotations__ - except AttributeError: - annotations = type(door_gizmos).__annotations__ - for prop in annotations: - if prop in gizmo_prop_names: - layout.prop(door_gizmos, prop) - - def draw_window_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: - from bonsai.bim.module.model.window import GizmoWindowEdition - - window_gizmos = self.gizmos.window - gizmo_prop_names = {p.attr_name for p in GizmoWindowEdition.dimension_gizmo_props} - try: - annotations = window_gizmos.__annotations__ - except AttributeError: - annotations = type(window_gizmos).__annotations__ - for prop in annotations: - if prop in gizmo_prop_names: - layout.prop(window_gizmos, prop) - - def draw_stair_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: - from bonsai.bim.module.model.stair import GizmoStairEdition - - stair_gizmos = self.gizmos.stair - gizmo_prop_names = {p.attr_name for p in GizmoStairEdition.dimension_gizmo_props} - # Add special gizmos not in dimension_gizmo_props - special_gizmo_names = {"lock", "plus", "minus", "cycle"} - try: - annotations = stair_gizmos.__annotations__ - except AttributeError: - annotations = type(stair_gizmos).__annotations__ - for prop in annotations: - if prop in gizmo_prop_names or prop in special_gizmo_names: - layout.prop(stair_gizmos, prop) + annotations = type(self.gizmos).__annotations__ + for feature in tool.Parametric.EDIT_TYPES: + if feature.name in annotations: + box.prop(self.gizmos, feature.name) def draw_model_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: layout.prop(self, "occurrence_name_style") diff --git a/src/bonsai/bonsai/core/aggregate.py b/src/bonsai/bonsai/core/aggregate.py index 0b6719a180..037f80c6ba 100644 --- a/src/bonsai/bonsai/core/aggregate.py +++ b/src/bonsai/bonsai/core/aggregate.py @@ -93,6 +93,8 @@ def enter_aggregate_mode( aggregator: type[tool.Aggregate], obj: bpy.types.Object, ): + if not aggregator.get_aggregate_props().in_aggregate_mode: + aggregator.save_previous_selection() aggregator.update_previous_aggregate_mode_state() if aggregator.get_higher_aggregate(): aggregator.disable_aggregate_mode() @@ -107,6 +109,7 @@ def exit_aggregate_mode(aggregator: type[tool.Aggregate]): aggregator.enable_aggregate_mode(new_obj) else: aggregator.disable_aggregate_mode() + aggregator.restore_previous_selection() class IncompatibleAggregateError(Exception): diff --git a/src/bonsai/bonsai/core/model.py b/src/bonsai/bonsai/core/model.py index e975505381..874675ea7f 100644 --- a/src/bonsai/bonsai/core/model.py +++ b/src/bonsai/bonsai/core/model.py @@ -15,10 +15,13 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# This file was modified with the assistance of an AI coding tool. from __future__ import annotations -from typing import TYPE_CHECKING, Literal, Optional +import math +from typing import TYPE_CHECKING, Any, Literal, Optional if TYPE_CHECKING: import bpy @@ -31,6 +34,24 @@ if TYPE_CHECKING: OffsetType = Literal["CENTER", "EXTERIOR", "INTERIOR"] +# Arc sample count for fillet preview polylines. 24 samples produces a visually +# smooth arc at common viewport scales without bloating the GPU batch. +FILLET_DEFAULT_ARC_RESOLUTION = 24 +# Dot-product floor for treating two wall-axis segments as parallel — below +# this the projected intersection is too sensitive to floating-point noise +# to be useful as a junction apex. Calibrated to ~2° from parallel. +PARALLEL_DOT_THRESHOLD = 0.9994 +# Perpendicular distance (SI metres) under which two parallel wall axes are +# considered to share the same infinite line. Calibrated to absorb sub-50mm +# placement drift between authored-joined walls without merging genuinely +# offset parallel walls. +COLLINEAR_LINE_TOLERANCE = 0.05 +# Default proximity (SI metres) for classifying a layer offset against the +# canonical EXTERIOR / CENTER / INTERIOR baselines. Tight enough that ordinary +# millimetre-scale modelling intent always falls into the nearest baseline. +BASELINE_OFFSET_TOLERANCE = 0.001 + + def unjoin_walls( ifc: type[tool.Ifc], blender: type[tool.Blender], @@ -140,23 +161,73 @@ def align_objects( model.align_objects(reference_obj, objs, align_type) +def regenerate_wall_to_underside( + ifc: type[tool.Ifc], + geometry: type[tool.Geometry], + model: type[tool.Model], + wall_objs: list[bpy.types.Object], +) -> None: + """Re-clip walls to their connected underside objects after the slab has moved.""" + clipped_objs = [] + for obj in wall_objs: + wall = ifc.get_entity(obj) + slab_objs = model.get_connected_slab_objs(wall) + if not slab_objs: + continue + if ifc.is_moved(obj): + geometry.run_edit_object_placement(obj=obj) + # Sync each slab's Blender mesh to its current IFC representation before + # reading face geometry, so a changed profile is picked up correctly. + model.reload_body_representation(slab_objs) + model.remove_wall_to_underside_booleans(wall) + for slab_obj in slab_objs: + clip = model.get_slab_clipping_bmesh(slab_obj) + if clip: + model.clip_wall_to_slab(wall, clip) + clipped_objs.append(obj) + if clipped_objs: + model.reload_body_representation(clipped_objs) + + def extend_wall_to_slab( ifc: type[tool.Ifc], geometry: type[tool.Geometry], model: type[tool.Model], - slab_obj: bpy.types.Object, + slab_objs: list[bpy.types.Object], wall_objs: list[bpy.types.Object], ) -> None: - if not (clip := model.get_slab_clipping_bmesh(slab_obj)): - return # Nothing to clip? - slab = ifc.get_entity(slab_obj) + # If any wall is currently in item mode, exit it before modifying the + # representation. Leaving stale item objects around causes delete_ifc_item + # to later remove the extrusion (or other pre-boolean items) from inside + # the boolean chain, corrupting the IFC model. + geom_props = geometry.get_geometry_props() + if geom_props.representation_obj in wall_objs: + geometry.disable_item_mode() + clipped_walls = [] for obj in wall_objs: if ifc.is_moved(obj): geometry.run_edit_object_placement(obj=obj) wall = ifc.get_entity(obj) - model.clip_wall_to_slab(wall, clip) - model.connect_wall_to_slab(wall, slab) - model.reload_body_representation(wall_objs) + # Merge previously connected slabs with newly requested ones so that + # re-running the operator never produces duplicate booleans and never + # silently discards clips that were applied in an earlier call. + existing = model.get_connected_slab_objs(wall) + seen = {id(s) for s in existing} + all_slab_objs = list(existing) + [s for s in slab_objs if id(s) not in seen] + # Remove stale booleans once, then re-clip against the full set. + model.remove_wall_to_underside_booleans(wall) + did_clip = False + for slab_obj in all_slab_objs: + clip = model.get_slab_clipping_bmesh(slab_obj) + if not clip: + continue + model.clip_wall_to_slab(wall, clip) + model.connect_wall_to_slab(wall, ifc.get_entity(slab_obj)) + did_clip = True + if did_clip: + clipped_walls.append(obj) + if clipped_walls: + model.reload_body_representation(clipped_walls) class RequireTwoWallsError(Exception): @@ -173,3 +244,438 @@ class RequireAtLeastTwoElements(Exception): class RequireLayeredElement(Exception): pass + + +# --- Wall geometry math (pure) ------------------------------------------------ +# Tuple in / tuple out so these helpers run without ``bpy`` or ``mathutils``. +# Callers convert ``mathutils.Vector`` at the boundary. + + +def baseline_from_offset(offset: float, thickness: float, tolerance: float = BASELINE_OFFSET_TOLERANCE) -> str: + """Classify a numeric layer offset as EXTERIOR / CENTER / INTERIOR. + + Handles both POSITIVE and NEGATIVE direction_sense walls. Returns the + closest canonical baseline; falls back to ``"CENTER"`` when nothing is + within ``tolerance``.""" + candidates = ( + ("EXTERIOR", 0.0), + ("CENTER", -thickness / 2), + ("INTERIOR", -thickness), + ("EXTERIOR", thickness), + ("CENTER", thickness / 2), + ("INTERIOR", 0.0), + ) + best = min(candidates, key=lambda c: abs(offset - c[1])) + return best[0] if abs(offset - best[1]) < tolerance else "CENTER" + + +def project_axis_intersection( + seg_a: tuple[tuple[float, float, float], tuple[float, float, float]], + seg_b: tuple[tuple[float, float, float], tuple[float, float, float]], + parallel_threshold: float, +) -> Optional[tuple[float, float, float]]: + """Compute the 2D (X,Y plane) intersection of two world-space axis segments. + + Each segment is a pair of 3-tuples. Returns the intersection as a 3-tuple + (Z is the average of the four input Zs, for visual placement) or ``None`` if + the segments are parallel within ``parallel_threshold`` (a dot-product magnitude + threshold — see ``PARALLEL_DOT_THRESHOLD`` for the calibrated value).""" + p1, p2 = seg_a + p3, p4 = seg_b + d1x, d1y = p2[0] - p1[0], p2[1] - p1[1] + d2x, d2y = p4[0] - p3[0], p4[1] - p3[1] + d1_len = (d1x * d1x + d1y * d1y) ** 0.5 + d2_len = (d2x * d2x + d2y * d2y) ** 0.5 + if d1_len < 1e-9 or d2_len < 1e-9: + return None + dot = (d1x * d2x + d1y * d2y) / (d1_len * d2_len) + if abs(dot) >= parallel_threshold: + return None + denom = d1x * d2y - d1y * d2x + if abs(denom) < 1e-9: + return None + t = ((p3[0] - p1[0]) * d2y - (p3[1] - p1[1]) * d2x) / denom + ix = p1[0] + t * d1x + iy = p1[1] + t * d1y + iz = (p1[2] + p2[2] + p3[2] + p4[2]) / 4 + return (ix, iy, iz) + + +def opening_is_past_cut(min_t: float, cut_percentage: float) -> bool: + """True when the opening's near edge sits past the cut on the t axis. + + Strict inequality is load-bearing: a boundary touch or NaN keeps the + opening on both walls — the safe default when extent resolution fails.""" + return min_t > cut_percentage + + +def opening_is_before_cut(max_t: float, cut_percentage: float) -> bool: + """True when the opening's far edge sits before the cut on the t axis.""" + return max_t < cut_percentage + + +def opening_straddles_cut(min_t: float, max_t: float, cut_percentage: float) -> bool: + """True when the opening's extent crosses the cut on the t axis.""" + return min_t < cut_percentage < max_t + + +WallJoinState = Literal["joined", "collinear", "intersect", "none"] + + +def classify_wall_join_state( + seg_a: tuple[tuple[float, float, float], tuple[float, float, float]], + seg_b: tuple[tuple[float, float, float], tuple[float, float, float]], + are_joined: bool, + parallel_threshold: float, + collinear_tolerance: float, +) -> tuple[WallJoinState, Optional[tuple[float, float, float]]]: + """Classify a wall pair's geometric state — ``(state, intersection)``. + + Priority: ``"joined"`` (caller-supplied flag) → ``"collinear"`` → + ``"intersect"`` (projected point returned) → ``"none"`` (parallel, + non-collinear).""" + if are_joined: + return "joined", None + if are_axes_collinear(seg_a, seg_b, parallel_threshold, collinear_tolerance): + return "collinear", None + intersection = project_axis_intersection(seg_a, seg_b, parallel_threshold) + if intersection is None: + return "none", None + return "intersect", intersection + + +def wall_join_preview_lines( + seg_a: tuple[tuple[float, float, float], tuple[float, float, float]], + seg_b: tuple[tuple[float, float, float], tuple[float, float, float]], + intersection: tuple[float, float, float], +) -> list[tuple[tuple[float, float, float], tuple[float, float, float]]]: + """Two segments showing each wall axis extending to ``intersection``. + + Each segment runs from the input axis's nearest endpoint to the + intersection, held at that wall's own Z. Returned in input order + ``[floor_a, floor_b]``.""" + ix, iy, _ = intersection + + def _nearest(seg: tuple[tuple[float, float, float], tuple[float, float, float]]) -> tuple[float, float, float]: + return min(seg, key=lambda p: (p[0] - ix) ** 2 + (p[1] - iy) ** 2) + + near_a = _nearest(seg_a) + near_b = _nearest(seg_b) + return [ + (near_a, (ix, iy, near_a[2])), + (near_b, (ix, iy, near_b[2])), + ] + + +def resolve_extend_walls_target( + target_obj: Any, + objs: list[Any], + reverse: bool, +) -> tuple[Any, list[Any]]: + """Pick which object is the extend-target and which are extended. + + Default direction: ``objs`` are extended to meet ``target_obj``. + Reversed direction (``reverse=True``) swaps the pair — equivalent to + having passed them in the opposite order. The swap is well-defined only + for the 1+1 case (one target + one other); for ``n>1`` it would be + ambiguous, so the default direction is preserved instead.""" + if reverse and target_obj is not None and len(objs) == 1: + return objs[0], [target_obj] + return target_obj, objs + + +def displacement_from_x_angle(height: float, x_angle: float) -> float: + """Top-edge horizontal displacement for a wall of given vertical ``height`` + and slope ``x_angle`` (radians). Inverse of ``x_angle_from_displacement``.""" + return height * math.tan(x_angle) + + +def x_angle_from_displacement(height: float, displacement: float) -> float: + """Recover slope ``x_angle`` (radians) from a top-edge horizontal displacement. + + ``height`` is clamped to ``max(height, 1e-6)`` so zero-height walls map + cleanly to ``±π/2`` instead of dividing by zero.""" + return math.atan2(displacement, max(height, 1e-6)) + + +def vertical_height_from_extrusion_depth(extrusion_depth: float, x_angle: float) -> float: + """Vertical height of a wall given its slanted extrusion depth and slope. + + ``IfcExtrudedAreaSolid.Depth`` measures along the (possibly slanted) extrusion + direction. The vertical height the user thinks of is ``depth * cos(x_angle)``. + Unit-agnostic: the result is in the same units as ``extrusion_depth``.""" + return extrusion_depth * abs(math.cos(x_angle)) + + +def extrusion_depth_from_vertical_height(vertical_height: float, x_angle: float) -> float: + """``vertical_height / cos(x_angle)`` with ``cos`` clamped at ``1e-6`` to + stay finite near ``±π/2``.""" + return vertical_height / max(abs(math.cos(x_angle)), 1e-6) + + +def length_and_height_from_extrusion( + extrusion_depth: float, + x_angle: float, + reference_line_x_extent: float, + unit_scale: float, +) -> tuple[float, float]: + """SI ``(length, vertical_height)`` of a LAYER2 wall. + + Height is the *vertical* projection of the slanted depth, not the + slanted depth itself.""" + length = reference_line_x_extent * unit_scale + height = vertical_height_from_extrusion_depth(extrusion_depth * unit_scale, x_angle) + return length, height + + +def are_axes_collinear( + seg_a: tuple[tuple[float, float, float], tuple[float, float, float]], + seg_b: tuple[tuple[float, float, float], tuple[float, float, float]], + parallel_threshold: float = PARALLEL_DOT_THRESHOLD, + line_tolerance: float = COLLINEAR_LINE_TOLERANCE, +) -> bool: + """True if both axis segments lie on the same infinite line in plan. + + Two conditions: directions must be (anti-)parallel within ``parallel_threshold``, + AND any endpoint of B must lie on A's infinite line within ``line_tolerance``. + Plan-only (Z ignored).""" + d1x, d1y = seg_a[1][0] - seg_a[0][0], seg_a[1][1] - seg_a[0][1] + d2x, d2y = seg_b[1][0] - seg_b[0][0], seg_b[1][1] - seg_b[0][1] + d1_len = (d1x * d1x + d1y * d1y) ** 0.5 + d2_len = (d2x * d2x + d2y * d2y) ** 0.5 + if d1_len < 1e-9 or d2_len < 1e-9: + return False + if abs((d1x * d2x + d1y * d2y) / (d1_len * d2_len)) < parallel_threshold: + return False + # Project seg_b[0] onto the infinite line through seg_a; the perpendicular + # distance to the original point tells us how far off the line B sits. + nx, ny = d1x / d1_len, d1y / d1_len + dx, dy = seg_b[0][0] - seg_a[0][0], seg_b[0][1] - seg_a[0][1] + t = dx * nx + dy * ny + proj_x = seg_a[0][0] + nx * t + proj_y = seg_a[0][1] + ny * t + perp_x = seg_b[0][0] - proj_x + perp_y = seg_b[0][1] - proj_y + return (perp_x * perp_x + perp_y * perp_y) ** 0.5 < line_tolerance + + +def closest_endpoint_midpoint( + seg_a: tuple[tuple[float, float, float], tuple[float, float, float]], + seg_b: tuple[tuple[float, float, float], tuple[float, float, float]], +) -> tuple[float, float, float]: + """Midpoint of the closest endpoint pair between two segments.""" + endpoints_a = (seg_a[0], seg_a[1]) + endpoints_b = (seg_b[0], seg_b[1]) + + def _distance_sq(p: tuple[float, float, float], q: tuple[float, float, float]) -> float: + return (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 + (p[2] - q[2]) ** 2 + + closest_pair = min(((a, b) for a in endpoints_a for b in endpoints_b), key=lambda pair: _distance_sq(*pair)) + a, b = closest_pair + return ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2) + + +def compute_path_connection_location( + seg_self: tuple[tuple[float, float, float], tuple[float, float, float]], + self_conn_type: str, + seg_other: tuple[tuple[float, float, float], tuple[float, float, float]], + other_conn_type: str, + parallel_threshold: float = PARALLEL_DOT_THRESHOLD, +) -> tuple[float, float, float]: + """World-space location of a single ``IfcRelConnectsPathElements`` between + two wall axes. + + Priority: ``self``'s ATSTART/ATEND endpoint → ``other``'s ATSTART/ATEND + endpoint → axis intersection → closest-endpoint midpoint fallback.""" + if self_conn_type == "ATSTART": + return seg_self[0] + if self_conn_type == "ATEND": + return seg_self[1] + if other_conn_type == "ATSTART": + return seg_other[0] + if other_conn_type == "ATEND": + return seg_other[1] + intersection = project_axis_intersection(seg_self, seg_other, parallel_threshold) + if intersection is not None: + return intersection + return closest_endpoint_midpoint(seg_self, seg_other) + + +def _vec_sub(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]: + return (a[0] - b[0], a[1] - b[1], a[2] - b[2]) + + +def _vec_dot(a: tuple[float, float, float], b: tuple[float, float, float]) -> float: + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + + +def _vec_cross(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]: + return (a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]) + + +def _vec_length(v: tuple[float, float, float]) -> float: + return (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]) ** 0.5 + + +def _rotate_around_axis( + v: tuple[float, float, float], + axis: tuple[float, float, float], + angle: float, +) -> tuple[float, float, float]: + """Rotate ``v`` around unit-length ``axis`` by ``angle`` radians.""" + cos_a = math.cos(angle) + sin_a = math.sin(angle) + dot = _vec_dot(axis, v) + cross = _vec_cross(axis, v) + k = 1.0 - cos_a + return ( + v[0] * cos_a + cross[0] * sin_a + axis[0] * dot * k, + v[1] * cos_a + cross[1] * sin_a + axis[1] * dot * k, + v[2] * cos_a + cross[2] * sin_a + axis[2] * dot * k, + ) + + +def compute_fillet_polylines( + seg_a: tuple[tuple[float, float, float], tuple[float, float, float]], + seg_b: tuple[tuple[float, float, float], tuple[float, float, float]], + radius: float, + arc_resolution: int = FILLET_DEFAULT_ARC_RESOLUTION, + parallel_threshold: float = PARALLEL_DOT_THRESHOLD, +) -> dict: + """Preview polylines for a circular fillet at the junction of two axes. + + Returns a dict with ``valid``, ``reason``, ``intersection``, ``tangent_a`` + / ``tangent_b``, ``arc`` (``arc_resolution + 1`` samples), ``arc_center``, + ``arc_radius``, ``sweep_angle``, ``sweep_axis``, ``tangent_offset``, + ``wall_a_join_side`` / ``wall_b_join_side`` (ATSTART/ATEND/None), + ``invalid_radius`` (tangent overshoots — arc + tangents still populated + for warning rendering), and ``invalid_axes`` (set on parallel).""" + blank: dict = { + "valid": False, + "reason": None, + "intersection": None, + "tangent_a": None, + "tangent_b": None, + "arc": [], + "arc_center": None, + "arc_radius": radius, + "sweep_angle": 0.0, + "sweep_axis": None, + "tangent_offset": 0.0, + "wall_a_join_side": None, + "wall_b_join_side": None, + "invalid_radius": False, + "invalid_axes": None, + } + + intersection = project_axis_intersection(seg_a, seg_b, parallel_threshold) + if intersection is None: + return {**blank, "reason": "parallel", "invalid_axes": [seg_a, seg_b]} + + def _classify(seg, ipt): + d0 = (seg[0][0] - ipt[0]) ** 2 + (seg[0][1] - ipt[1]) ** 2 + (seg[0][2] - ipt[2]) ** 2 + d1 = (seg[1][0] - ipt[0]) ** 2 + (seg[1][1] - ipt[1]) ** 2 + (seg[1][2] - ipt[2]) ** 2 + if d0 <= d1: + return seg[0], seg[1], "ATSTART" + return seg[1], seg[0], "ATEND" + + near_a, far_a, side_a = _classify(seg_a, intersection) + near_b, far_b, side_b = _classify(seg_b, intersection) + + # Direction along each segment AWAY from the corner. ``far - intersection`` + # handles both the shared-corner and extended-axes cases uniformly. + dir_a_raw = _vec_sub(far_a, intersection) + dir_b_raw = _vec_sub(far_b, intersection) + far_len_a = _vec_length(dir_a_raw) + far_len_b = _vec_length(dir_b_raw) + if far_len_a < 1e-9 or far_len_b < 1e-9: + return {**blank, "reason": "near_collinear", "intersection": intersection} + dir_a = (dir_a_raw[0] / far_len_a, dir_a_raw[1] / far_len_a, dir_a_raw[2] / far_len_a) + dir_b = (dir_b_raw[0] / far_len_b, dir_b_raw[1] / far_len_b, dir_b_raw[2] / far_len_b) + + cos_angle = max(-1.0, min(1.0, _vec_dot(dir_a, dir_b))) + angle = math.acos(cos_angle) + sweep_angle = math.pi - angle + if sweep_angle < 1e-3 or sweep_angle > math.pi - 1e-3: + return { + **blank, + "reason": "near_collinear", + "intersection": intersection, + "sweep_angle": sweep_angle, + "wall_a_join_side": side_a, + "wall_b_join_side": side_b, + } + + tangent_offset = radius * math.tan(sweep_angle / 2) + tangent_a = ( + intersection[0] + dir_a[0] * tangent_offset, + intersection[1] + dir_a[1] * tangent_offset, + intersection[2] + dir_a[2] * tangent_offset, + ) + tangent_b = ( + intersection[0] + dir_b[0] * tangent_offset, + intersection[1] + dir_b[1] * tangent_offset, + intersection[2] + dir_b[2] * tangent_offset, + ) + + plane_normal_raw = _vec_cross(dir_a, dir_b) + pn_len = _vec_length(plane_normal_raw) + if pn_len < 1e-9: + return {**blank, "reason": "near_collinear", "intersection": intersection} + plane_normal = ( + plane_normal_raw[0] / pn_len, + plane_normal_raw[1] / pn_len, + plane_normal_raw[2] / pn_len, + ) + + perp_a = _vec_cross(plane_normal, dir_a) + if _vec_dot(perp_a, dir_b) < 0: + perp_a = (-perp_a[0], -perp_a[1], -perp_a[2]) + + arc_center = ( + tangent_a[0] + perp_a[0] * radius, + tangent_a[1] + perp_a[1] * radius, + tangent_a[2] + perp_a[2] * radius, + ) + + v_a = _vec_sub(tangent_a, arc_center) + v_b = _vec_sub(tangent_b, arc_center) + sweep_axis = plane_normal + if _vec_dot(_vec_cross(v_a, v_b), plane_normal) < 0: + sweep_axis = (-plane_normal[0], -plane_normal[1], -plane_normal[2]) + + arc_points: list[tuple[float, float, float]] = [] + for i in range(arc_resolution + 1): + t = i / arc_resolution + rotated = _rotate_around_axis(v_a, sweep_axis, sweep_angle * t) + arc_points.append( + ( + arc_center[0] + rotated[0], + arc_center[1] + rotated[1], + arc_center[2] + rotated[2], + ) + ) + + # Overshoot check only for convex fillets (positive ``tangent_offset``); + # the inverted-fillet case puts tangents past the intersection. + invalid_radius = tangent_offset > 0 and (tangent_offset > far_len_a or tangent_offset > far_len_b) + + return { + "valid": not invalid_radius, + "reason": "invalid_radius" if invalid_radius else None, + "intersection": intersection, + "tangent_a": tangent_a, + "tangent_b": tangent_b, + "arc": arc_points, + "arc_center": arc_center, + "arc_radius": radius, + "sweep_angle": sweep_angle, + "sweep_axis": sweep_axis, + "tangent_offset": tangent_offset, + "wall_a_join_side": side_a, + "wall_b_join_side": side_b, + "leg_a_available": far_len_a, + "leg_b_available": far_len_b, + "invalid_radius": invalid_radius, + "invalid_axes": None, + } diff --git a/src/bonsai/bonsai/core/product.py b/src/bonsai/bonsai/core/product.py new file mode 100644 index 0000000000..4eaddc833d --- /dev/null +++ b/src/bonsai/bonsai/core/product.py @@ -0,0 +1,64 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +from __future__ import annotations + +import math +from collections.abc import Iterable +from typing import TYPE_CHECKING + +import bonsai.core.geometry + +if TYPE_CHECKING: + import bpy + + import bonsai.tool as tool + + +Z_ROTATION_ALIGNMENT_TOLERANCE = 1e-9 + + +def _z_rotation_diff(target_z: float, source_z: float) -> float: + """Signed Z-Euler difference wrapped to [-π, π].""" + return (target_z - source_z + math.pi) % (2 * math.pi) - math.pi + + +def copy_z_rotation_to_selected( + ifc: type[tool.Ifc], + geometry: type[tool.Geometry], + surveyor: type[tool.Surveyor], + *, + active: bpy.types.Object, + targets: Iterable[bpy.types.Object], + flip: bool = False, +) -> int: + """Apply ``active``'s Z-Euler rotation to each target.""" + source_z = surveyor.get_z_rotation(active) + if flip: + source_z += math.pi + rotated = 0 + for obj in targets: + if abs(_z_rotation_diff(surveyor.get_z_rotation(obj), source_z)) < Z_ROTATION_ALIGNMENT_TOLERANCE: + continue + surveyor.set_z_rotation(obj, source_z) + rotated += 1 + if ifc.get_entity(obj) is not None: + bonsai.core.geometry.edit_object_placement(ifc, geometry, surveyor, obj=obj) + return rotated diff --git a/src/bonsai/bonsai/core/root.py b/src/bonsai/bonsai/core/root.py index 3a283a6a65..2276e0623f 100644 --- a/src/bonsai/bonsai/core/root.py +++ b/src/bonsai/bonsai/core/root.py @@ -20,8 +20,6 @@ from __future__ import annotations from typing import TYPE_CHECKING, Optional -import ifcopenshell.util.element - if TYPE_CHECKING: import bpy import ifcopenshell @@ -58,31 +56,12 @@ def copy_class( geometry.change_object_data(obj, data, is_global=True) geometry.rename_object(data, geometry.get_representation_name(ifc.get_entity(data))) # Only assign styles if element doesn't get them from material - if not _has_material_styles(ifc, new): + if not root.has_material_styles(new): root.assign_body_styles(new, obj) collector.assign(obj) return new -def _has_material_styles(ifc: type[tool.Ifc], element: ifcopenshell.entity_instance) -> bool: - """Check if element has styles defined through its material. - - Returns True if any constituent material has a style representation, - which means styles should NOT be applied directly to the geometry. - """ - materials = ifcopenshell.util.element.get_materials(element) - - if not materials: - return False - - # Check if any of the constituent materials have styles - for material in materials: - if hasattr(material, "HasRepresentation") and material.HasRepresentation: - return True - - return False - - def assign_class( ifc: type[tool.Ifc], collector: type[tool.Collector], diff --git a/src/bonsai/bonsai/core/spatial.py b/src/bonsai/bonsai/core/spatial.py index 5ce6d7c253..98db273f46 100644 --- a/src/bonsai/bonsai/core/spatial.py +++ b/src/bonsai/bonsai/core/spatial.py @@ -64,10 +64,11 @@ def assign_container( spatial.disable_editing(obj) all_elements.add(root_element) all_elements.update(spatial.get_decomposition(root_element)) - if products := [e for e in root_elements if spatial.can_contain(container, root_element)]: + if products := [e for e in root_elements if spatial.can_contain(container, e)]: ifc.run("spatial.assign_container", products=products, relating_structure=container) for element in all_elements: - collector.assign(ifc.get_object(element)) + if obj := ifc.get_object(element): + collector.assign(obj) def enable_editing_container(spatial: type[tool.Spatial], obj: bpy.types.Object) -> None: diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 5b141320c6..c15955824e 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -414,6 +414,17 @@ class Drawing: def update_embedded_svg_location(cls, uri, old_location, new_location): pass +@interface +class Duplicate: + def get_decomposition_relationships(cls, objs): pass + def get_connection_relationships(cls, objs): pass + def get_port_connection_relationships(cls, objs): pass + def recreate_decompositions(cls, relationships, old_to_new): pass + def recreate_connections(cls, relationship, old_to_new): pass + def recreate_port_connections(cls, snapshot, old_to_new): pass + def consume_warnings(cls): pass + + @interface class Feature: def add_feature(cls, featured_obj, featured_objs): pass @@ -443,6 +454,7 @@ class Geometry: def get_representation_name(cls, representation): pass def get_styles(cls, obj): pass def get_total_representation_items(cls, obj): pass + def has_axis_representation(cls, element): pass def has_data_users(cls, data): pass def has_material_style_override(cls, obj): pass def import_representation_parameters(cls, data): pass @@ -666,6 +678,9 @@ class Model: def export_profile(cls, obj, position=None): pass def generate_occurrence_name(cls, element_type, ifc_class): pass def get_extrusion(cls, representation): pass + def get_connected_slab_objs(cls, wall): pass + def get_connected_wall_objs(cls, slab): pass + def has_underside_connection(cls, element): pass def get_manual_booleans(cls, element): pass def get_material_layer_parameters(cls, element): pass def get_slab_clipping_bmesh(cls, obj): pass @@ -681,6 +696,7 @@ class Model: def regenerate_profile(cls, obj): pass def regenerate_slab(cls, obj): pass def reload_body_representation(cls, obj_or_objects): pass + def remove_wall_to_underside_booleans(cls, wall): pass def replace_object_ifc_representation(cls, ifc_file, ifc_context, obj, new_representation): pass @@ -774,6 +790,12 @@ class Profile: def get_profile(cls, element): pass +@interface +class Parametric: + def get_geom_generation(cls) -> int: pass + def refresh_post_commit(cls, operator) -> None: pass + + @interface class Pset: def add_proposed_property(cls, name, value, props): pass @@ -857,13 +879,13 @@ class Root: def assign_body_styles(cls, element, obj): pass def copy_representation(cls, source, dest): pass def does_type_have_representations(cls, element): pass - def get_decomposition_relationships(cls, objs): pass def get_default_container(cls): pass def get_element_representation(cls, element, context): pass def get_element_type(cls, element): pass def get_object_name(cls, obj): pass def get_object_representation(cls, obj): pass def get_representation_context(cls, representation): pass + def has_material_styles(cls, element): pass def is_containable(cls, element): pass def is_drawing_annotation(cls, element): pass def is_element_a(cls, element, ifc_class): pass @@ -871,7 +893,6 @@ class Root: def is_in_nest_mode(cls, element): pass def is_spatial_element(cls, element): pass def link_object_data(cls, source_obj, destination_obj): pass - def recreate_decompositions(cls, relationships, old_to_new): pass def run_geometry_add_representation(cls, obj=None, context=None, ifc_representation_class=None, profile_set_usage=None): pass def set_object_name(cls, obj, element): pass @@ -1015,6 +1036,8 @@ class Spatial: def get_container(cls, element): pass def get_decomposed_elements(cls, container, recursive): pass def get_decomposition(cls, element): pass + def get_host_element(cls, filling): pass + def get_host_wall(cls, filling): pass def get_object_matrix(cls, obj): pass def get_relative_object_matrix(cls, target_obj, relative_to_obj): pass def get_root_element(cls, element): pass @@ -1135,6 +1158,8 @@ class Style: @interface class Surveyor: def get_absolute_matrix(cls, obj): pass + def get_z_rotation(cls, obj): pass + def set_z_rotation(cls, obj, z): pass @interface @@ -1201,6 +1226,42 @@ class Voider: def void(cls, opening_obj, building_obj): pass +@interface +class Array: + def bake_children_transform(cls, parent_element, item): pass + def constrain_children_to_parent(cls, parent_element): pass + def get_all_children_objects(cls, parent_element): pass + def get_all_objects(cls, parent_element): pass + def get_child_layer_index(cls, child_element): pass + def get_children_objects(cls, modifier_data): pass + def get_modifiers_data(cls, parent_element): pass + def get_parent_element(cls, element): pass + def get_parent_object(cls, element): pass + def remove_constraints(cls, parent_element): pass + def set_children_lock_state(cls, parent_element, item, lock_state): pass + + +@interface +class Slab: + def read_geometry(cls, obj): pass + + +@interface +class Wall: + def collinear_boundary_world(cls, seg_a, seg_b): pass + def compute_wall_fillet_geometry(cls, wall_a_obj, wall_b_obj, radius, arc_resolution): pass + def get_axis_local_extent(cls, wall): pass + def get_length_and_height(cls, wall): pass + def get_world_reference_line(cls, obj): pass + def get_x_angle(cls, wall): pass + def has_layer2_usage(cls, wall): pass + def is_straight_axis(cls, wall): pass + def path_connection_location_world(cls, seg_self, self_conn_type, seg_other, other_conn_type, parallel_threshold): pass + def read_geometry(cls, obj): pass + def validate_for_parametric_edit(cls, obj): pass + def walk_connected_walls(cls, start_element, node_cap): pass + + @interface class Web: pass diff --git a/src/bonsai/bonsai/tool/__init__.py b/src/bonsai/bonsai/tool/__init__.py index 06e498e8be..03716236e1 100644 --- a/src/bonsai/bonsai/tool/__init__.py +++ b/src/bonsai/bonsai/tool/__init__.py @@ -20,6 +20,7 @@ # ruff: noqa: F401 from bonsai.tool.aggregate import Aggregate +from bonsai.tool.array import Array from bonsai.tool.attribute import Attribute from bonsai.tool.bcf import Bcf from bonsai.tool.blender import Blender @@ -37,6 +38,7 @@ from bonsai.tool.debug import Debug from bonsai.tool.demo import Demo from bonsai.tool.document import Document from bonsai.tool.drawing import Drawing +from bonsai.tool.duplicate import Duplicate from bonsai.tool.feature import Feature from bonsai.tool.geometry import Geometry from bonsai.tool.georeference import Georeference @@ -51,6 +53,7 @@ from bonsai.tool.misc import Misc from bonsai.tool.model import Model from bonsai.tool.nest import Nest from bonsai.tool.owner import Owner +from bonsai.tool.parametric import Parametric from bonsai.tool.patch import Patch from bonsai.tool.polyline import Polyline from bonsai.tool.profile import Profile @@ -63,6 +66,7 @@ from bonsai.tool.resource import Resource from bonsai.tool.root import Root from bonsai.tool.search import Search from bonsai.tool.sequence import Sequence +from bonsai.tool.slab import Slab from bonsai.tool.snap import Snap from bonsai.tool.spatial import Spatial from bonsai.tool.structural import Structural @@ -72,4 +76,5 @@ from bonsai.tool.system import System from bonsai.tool.tester import Tester from bonsai.tool.type import Type from bonsai.tool.unit import Unit +from bonsai.tool.wall import Wall from bonsai.tool.web import Web diff --git a/src/bonsai/bonsai/tool/aggregate.py b/src/bonsai/bonsai/tool/aggregate.py index 1f7f601862..9106024ad2 100644 --- a/src/bonsai/bonsai/tool/aggregate.py +++ b/src/bonsai/bonsai/tool/aggregate.py @@ -205,6 +205,27 @@ class Aggregate(bonsai.core.tool.Aggregate): props.in_aggregate_mode = True return {"FINISHED"} + @classmethod + def save_previous_selection(cls) -> None: + props = cls.get_aggregate_props() + props.previously_selected_objects.clear() + for obj in bpy.context.selected_objects: + entry = props.previously_selected_objects.add() + entry.obj = obj + + @classmethod + def restore_previous_selection(cls) -> None: + props = cls.get_aggregate_props() + for obj in bpy.context.selected_objects: + obj.select_set(False) + for entry in props.previously_selected_objects: + if entry.obj: + try: + entry.obj.select_set(True) + except Exception: + pass + props.previously_selected_objects.clear() + @classmethod def disable_aggregate_mode(cls): context = bpy.context diff --git a/src/bonsai/bonsai/tool/array.py b/src/bonsai/bonsai/tool/array.py new file mode 100644 index 0000000000..d5e35bb6f9 --- /dev/null +++ b/src/bonsai/bonsai/tool/array.py @@ -0,0 +1,207 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Bonsai parametric array service. + +Top-level array-domain helpers. The ``BBIM_Array`` pset on a parent ``IfcElement`` +holds the list of layers; each layer holds the GUIDs of its child replicas. These +helpers navigate that graph and manage the Blender-side CHILD_OF constraint that +pins children to the parent's matrix_world.""" + +from __future__ import annotations + +import json +from collections.abc import Generator +from typing import TYPE_CHECKING, Any + +import bpy +import ifcopenshell +import ifcopenshell.util.element + +import bonsai.core.tool +import bonsai.tool as tool + +if TYPE_CHECKING: + from ifcopenshell import entity_instance + + +class Array(bonsai.core.tool.Array): + @classmethod + def bake_children_transform(cls, parent_element: entity_instance, item: int) -> None: + modifier_data = list(cls.get_modifiers_data(parent_element))[item] + children = cls.get_children_objects(modifier_data) + for child in children: + constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None) + if constraint: + with bpy.context.temp_override(object=child): + bpy.ops.constraint.apply(constraint=constraint.name, owner="OBJECT") + + @classmethod + def constrain_children_to_parent(cls, parent_element: ifcopenshell.entity_instance) -> None: + if not (parent_obj := tool.Ifc.get_object(parent_element)): + return # Filtered out, arrayed void, etc + assert isinstance(parent_obj, bpy.types.Object) + children = cls.get_all_children_objects(parent_element) + for child in children: + constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None) + if constraint: + child.constraints.remove(constraint) + constraint = child.constraints.new("CHILD_OF") + constraint.name = "BBIM_Array_CHILD_OF" + assert isinstance(constraint, bpy.types.ChildOfConstraint) + constraint.target = parent_obj + + @classmethod + def set_children_lock_state( + cls, parent_element: ifcopenshell.entity_instance, item: int, lock_state: bool = True + ) -> None: + modifier_data = list(cls.get_modifiers_data(parent_element))[item] + children = cls.get_children_objects(modifier_data) + for child_obj in children: + tool.Blender.lock_transform(child_obj, lock_state) + + @classmethod + def remove_constraints(cls, parent_element: ifcopenshell.entity_instance) -> None: + children = cls.get_all_children_objects(parent_element) + for child in children: + constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None) + if constraint: + child.constraints.remove(constraint) + + @classmethod + def get_all_objects(cls, parent_element: ifcopenshell.entity_instance) -> list[bpy.types.Object]: + parent_obj = tool.Ifc.get_object(parent_element) + assert isinstance(parent_obj, bpy.types.Object) + children_objects = list(cls.get_all_children_objects(parent_element)) + array_objects = [parent_obj] + children_objects # We ensure the parent is at index 0 + return array_objects + + @classmethod + def get_all_children_objects( + cls, parent_element: ifcopenshell.entity_instance + ) -> Generator[bpy.types.Object, None, None]: + for array_modifier in cls.get_modifiers_data(parent_element): + yield from cls.get_children_objects(array_modifier) + + @classmethod + def get_parent_element(cls, element: entity_instance) -> entity_instance | None: + """Inverse of ``get_all_children_objects``: resolve an array element + back to its parent entity. Returns ``None`` when the element isn't + part of a Bonsai parametric array, or the stored Parent GUID does + not resolve in the current file (this is a data-integrity warning + and is logged to the console).""" + pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + if not pset: + return None + parent_guid = pset["Parent"] + try: + return tool.Ifc.get().by_guid(parent_guid) + except RuntimeError: + print( + f"BBIM_Array.Parent GUID {parent_guid!r} on {element} does not resolve " + f"in the current file — array integrity may be broken." + ) + return None + + @classmethod + def get_parent_object(cls, element: entity_instance) -> bpy.types.Object | None: + parent_element = cls.get_parent_element(element) + if parent_element is None: + return None + return tool.Ifc.get_object(parent_element) + + @classmethod + def get_modifiers_data(cls, parent_element: ifcopenshell.entity_instance) -> Generator[dict[str, Any], None, None]: + array_pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array") + yield from json.loads(array_pset["Data"]) + + @classmethod + def get_children_objects(cls, modifier_data: dict[str, Any]) -> Generator[bpy.types.Object, None, None]: + child_guid: str + for child_guid in modifier_data["children"]: + child_obj = tool.Blender.get_object_from_guid(child_guid) + if child_obj: + yield child_obj + + @classmethod + def get_array_root_guid(cls, element: entity_instance) -> str: + """Walk ``BBIM_Array.Parent`` upwards and return the topmost ancestor's + GlobalId. For an element with no ``BBIM_Array`` pset (independent + window, never arrayed, or former-child after the apply path), returns + the element's own GlobalId — its "family" is just itself.""" + current = element + seen: set[str] = set() + while True: + pset = ifcopenshell.util.element.get_pset(current, "BBIM_Array") + parent_guid = pset.get("Parent") if pset else None + if not parent_guid or parent_guid == current.GlobalId or parent_guid in seen: + return current.GlobalId + seen.add(parent_guid) + try: + current = tool.Ifc.get().by_guid(parent_guid) + except RuntimeError: + return current.GlobalId + + @classmethod + def get_parametric_propagation_targets(cls, element: entity_instance) -> list[entity_instance]: + """Type-occurrences that should receive parametric updates when + ``element`` is edited. + + Returns occurrences in ``element``'s Bonsai array family. When + ``element`` is not part of any array, returns the type-occurrence + peers that are likewise free of ``BBIM_Array`` (preserving the + bulk-edit-by-type UX for standalone parametric elements). An + occurrence whose ``BBIM_Array`` root differs from ``element``'s root + is excluded — that is the "independent former child" case the array + apply path produces.""" + occurrences = tool.Ifc.get_all_element_occurrences(element) + element_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + if not element_pset: + return [o for o in occurrences if not ifcopenshell.util.element.get_pset(o, "BBIM_Array")] + element_root = cls.get_array_root_guid(element) + return [o for o in occurrences if cls.get_array_root_guid(o) == element_root] + + @classmethod + def get_child_layer_index(cls, child_element: entity_instance) -> int | None: + """Index of the layer that produced ``child_element``, or ``None`` + if the child is unparented, missing from the parent's data, or the + parent's pset is unreadable. Total: never raises.""" + pset = ifcopenshell.util.element.get_pset(child_element, "BBIM_Array") + if not pset: + return None + parent_guid = pset.get("Parent") + if not parent_guid or parent_guid == child_element.GlobalId: + return None + try: + parent_element = tool.Ifc.get().by_guid(parent_guid) + except RuntimeError: + return None + data_text = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array", "Data") + if not data_text: + return None + try: + layers = json.loads(data_text) + except (ValueError, TypeError): + return None + child_guid = child_element.GlobalId + for i, layer in enumerate(layers): + if child_guid in layer.get("children", []): + return i + return None diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index fac9657dbd..78b978849d 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -15,12 +15,14 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# This file was modified with the assistance of an AI coding tool. from __future__ import annotations import contextlib import importlib -import json +import math import os import platform import subprocess @@ -28,7 +30,7 @@ import sys import tempfile import traceback import types -from collections.abc import Callable, Generator, Iterable, Sequence, Sized +from collections.abc import Callable, Generator, Iterable, Mapping, Sequence, Sized from datetime import datetime from functools import cache, lru_cache from pathlib import Path @@ -45,7 +47,6 @@ from typing import ( import bmesh import bpy -import ifcopenshell.api import ifcopenshell.util.element import numpy as np import numpy.typing as npt @@ -55,12 +56,12 @@ from mathutils import Matrix, Vector import bonsai.bim import bonsai.core.tool import bonsai.tool as tool -from bonsai.bim.ifc import IFC_CONNECTED_TYPE if TYPE_CHECKING: import bpy.stub_internal.rna_enums as rna_enums from sun_position.properties import SunPosProperties + from bonsai.bim.ifc import IFC_CONNECTED_TYPE from bonsai.bim.module.attribute.prop import BIMAttributeProperties from bonsai.bim.module.constraint.prop import ( BIMConstraintProperties, @@ -97,6 +98,19 @@ VIEWPORT_ATTRIBUTES = [ OBJECT_DATA_TYPE = Union[bpy.types.Mesh, bpy.types.Curve, bpy.types.Camera] +_RAILING_MODIFIER_IFC_CLASSES = ("IfcRailing", "IfcRailingType") +_STAIR_MODIFIER_IFC_CLASSES = ( + "IfcStairFlight", + "IfcStairFlightType", + "IfcMember", + "IfcMemberType", + "IfcStair", + "IfcStairType", +) +_WINDOW_MODIFIER_IFC_CLASSES = ("IfcWindow", "IfcWindowType", "IfcWindowStyle") +_DOOR_MODIFIER_IFC_CLASSES = ("IfcDoor", "IfcDoorType", "IfcDoorStyle") +_ROOF_MODIFIER_IFC_CLASSES = ("IfcRoof", "IfcRoofType") + class Blender(bonsai.core.tool.Blender): OBJECT_TYPES_THAT_SUPPORT_EDIT_MODE = ("MESH", "CURVE", "SURFACE", "META", "FONT", "LATTICE", "ARMATURE") @@ -216,15 +230,22 @@ class Blender(bonsai.core.tool.Blender): @classmethod def get_active_object(cls, is_selected: bool = False) -> Union[bpy.types.Object, None]: - """Gets the active object + """Return the active object, or ``None`` when the current context + exposes neither ``active_object`` nor a ``view_layer`` (stripped + operator contexts). :param is_selected: If true, the active object also needs to be selected. """ - if obj := (getattr(bpy.context, "active_object", None) or bpy.context.view_layer.objects.active): - if not is_selected: - return obj - if obj.select_get(): - return obj + obj = getattr(bpy.context, "active_object", None) + if obj is None: + view_layer = getattr(bpy.context, "view_layer", None) + if view_layer is not None: + obj = view_layer.objects.active + if obj is None: + return None + if is_selected and not obj.select_get(): + return None + return obj @classmethod def get_selected_objects(cls, include_active: bool = True) -> set[bpy.types.Object]: @@ -415,6 +436,189 @@ class Blender(bonsai.core.tool.Blender): with bpy.context.temp_override(**cls.get_viewport_context()): bpy.ops.wm.tool_set_by_id(name=tool_name) + @classmethod + def are_viewport_gizmos_enabled(cls) -> bool: + """Central gate every Bonsai gizmo poll / decorator draw checks before + rendering. Centralises the read of + ``gizmos.draw_gizmos_in_3d_viewport`` from addon preferences.""" + return cls.get_addon_preferences().gizmos.draw_gizmos_in_3d_viewport + + class DecoratorColors(NamedTuple): + selected: tuple + unselected: tuple + special: tuple + error: tuple + background: tuple + + @classmethod + def get_decorator_colors(cls) -> Blender.DecoratorColors: + """The five ``decorator_color_*`` fields read together so each viewport + decorator's draw callback resolves them in one call instead of five.""" + prefs = cls.get_addon_preferences() + return cls.DecoratorColors( + selected=prefs.decorator_color_selected, + unselected=prefs.decorator_color_unselected, + special=prefs.decorator_color_special, + error=prefs.decorator_color_error, + background=prefs.decorator_color_background, + ) + + class ViewportDecorator: + """Shared ``SpaceView3D.draw_handler_add`` lifecycle for feature decorators. + + Single-handler subclasses set ``draw_method`` (default ``"draw"``); the + handler binds at ``POST_VIEW``. Multi-handler subclasses set + ``draw_methods`` to a tuple of ``(method_name, phase)`` pairs; when it + is non-``None`` it supersedes ``draw_method``. + + Decorators whose ``install`` must accept extra arguments (e.g. a callback + or a precomputed bmesh) override ``install`` themselves.""" + + draw_method: str = "draw" + draw_methods: tuple[tuple[str, str], ...] | None = None + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + cls.handlers = [] + cls.is_installed = False + # Fail loudly at class-definition time if draw_method / draw_methods + # names an attribute the class doesn't expose. Without this, a typo + # only surfaces on the first redraw — as a silent missing-attribute + # handler — which may be far from the offending declaration. + method_names = ( + tuple(name for name, _phase in cls.draw_methods) if cls.draw_methods is not None else (cls.draw_method,) + ) + for name in method_names: + if getattr(cls, name, None) is None: + raise TypeError(f"{cls.__name__}: draw method {name!r} is declared but not defined on the class") + + @classmethod + def install(cls, context: bpy.types.Context) -> None: + if cls.is_installed: + cls.uninstall() + handler = cls() + bindings = cls.draw_methods if cls.draw_methods is not None else ((cls.draw_method, "POST_VIEW"),) + # Rollback partial registrations on any draw_handler_add failure, so + # cls.handlers never ends up holding a half-installed set. + added: list = [] + try: + for method_name, phase in bindings: + added.append( + bpy.types.SpaceView3D.draw_handler_add( + getattr(handler, method_name), (context,), "WINDOW", phase + ) + ) + except Exception: + for h in added: + try: + bpy.types.SpaceView3D.draw_handler_remove(h, "WINDOW") + except ValueError: + pass + raise + cls.handlers = added + cls.is_installed = True + + @classmethod + def uninstall(cls) -> None: + for h in cls.handlers: + try: + bpy.types.SpaceView3D.draw_handler_remove(h, "WINDOW") + except ValueError: + pass + cls.handlers.clear() + cls.is_installed = False + + @staticmethod + def _lookup_active_instance(gizmo_cls: type, context: bpy.types.Context) -> Optional[Any]: + """Return the live ``GizmoGroup`` instance registered under + ``context.region``, or ``None`` if there isn't one. The per-region + weakref dict on the gizmo class is populated by ``setup()``; multi- + viewport setups put one entry per region in it so each region's + decorator sees only its own region's hover state.""" + instances = getattr(gizmo_cls, "_active_instances", None) + if not instances: + return None + region = getattr(context, "region", None) + if region is None: + return None + ref = instances.get(region.as_pointer()) + if ref is None: + return None + return ref() + + def _cursor_icon_hovered(self, gizmo_cls: type, attr_name: str, context: bpy.types.Context) -> bool: + """True iff the gizmo group instance in the current region exposes a gizmo + under ``attr_name`` that reports as highlighted. Any access exception is + swallowed so a transient bpy-state hiccup never breaks the draw loop.""" + inst = self._lookup_active_instance(gizmo_cls, context) + if inst is None: + return False + try: + return bool(getattr(inst, attr_name).is_highlight) + except (AttributeError, ReferenceError): + return False + + @classmethod + def sync_all( + cls, + context: bpy.types.Context, + enabled: Mapping[type[Blender.ViewportDecorator], bool], + ) -> None: + """Drive each listed decorator to its desired install state in one call. + + Each entry whose value is ``True`` ends up installed; each entry whose + value is ``False`` ends up uninstalled. Pass ``True`` for always-on + overlays so they survive subsequent file loads.""" + for decorator_cls, should_install in enabled.items(): + if should_install: + decorator_cls.install(context) + else: + decorator_cls.uninstall() + + @classmethod + def is_view_top_down(cls, context: bpy.types.Context, threshold: float = 0.9659) -> bool: + """True when the viewport camera is looking ~straight down (or up) the world Z axis. + + Default threshold of 0.9659 = cos(15°) — a 15° tilt cone around ±world Z. + Above the threshold the world-Z axis projects to a small fraction of its + true length on screen, so callers that lay icons or markers out along + world Z should switch to a screen-space offset and any gizmo whose intent + is specifically "vertical" loses its visual cue. The cone is kept narrow + so vertical-intent gizmos stay visible across the typical orbit range of + 3D viewport work and drop out only near genuine plan view.""" + rv3d = context.region_data + if rv3d is None: + return False + view_forward = Vector(rv3d.view_matrix.inverted().col[2][:3]).normalized() + return abs(view_forward.z) > threshold + + @classmethod + def top_down_factor(cls, context: bpy.types.Context, threshold: float = 0.9659) -> float: + """Continuous 0–1 ramp matching ``is_view_top_down``'s cone: 0 outside the + cone, ramping linearly to 1 at strict alignment with world Z. Callers that + want a proportional effect (an icon-stack lift growing as the view + approaches plan) use this in place of the boolean to avoid a one-frame + visual jump as the camera crosses the threshold.""" + rv3d = context.region_data + if rv3d is None: + return 0.0 + view_forward = Vector(rv3d.view_matrix.inverted().col[2][:3]).normalized() + alignment = abs(view_forward.z) + if alignment <= threshold: + return 0.0 + return (alignment - threshold) / (1.0 - threshold) + + @classmethod + def get_screen_up_world(cls, context: bpy.types.Context) -> Vector: + """World-space direction corresponding to the camera's up axis (screen-vertical). + + Returns ``+Y`` when region data is unavailable so callers can compute an + offset without a guard branch.""" + rv3d = context.region_data + if rv3d is None: + return Vector((0.0, 1.0, 0.0)) + return Vector(rv3d.view_matrix.inverted().col[1][:3]).normalized() + @classmethod def get_shader_editor_context(cls) -> Union[dict[str, Any], None]: for screen in bpy.data.screens: @@ -484,9 +688,13 @@ class Blender(bonsai.core.tool.Blender): @classmethod def update_all_viewports(cls, context: bpy.types.Context | None = None) -> None: + """Tag every visible 3D viewport for redraw. Silent no-op when no + screen attached (background mode, plug-out, mid-load_post).""" context = context or bpy.context - assert context.screen - for area in context.screen.areas: + screen = getattr(context, "screen", None) + if screen is None: + return + for area in screen.areas: if area.type == "VIEW_3D": area.tag_redraw() @@ -635,10 +843,11 @@ class Blender(bonsai.core.tool.Blender): op_text = "" if ui_context == "TOOL_HEADER" else text modifier_icon, modifier_str = cls.KEY_MODIFIERS.get(modifier, ("NONE", "")) - row = layout if ui_context == "TOOL_HEADER" else layout.row(align=True) module = sys.modules[module_name] icon_previews: Union[bpy.utils.previews.ImagePreviewCollection, None] icon_previews = getattr(module, "custom_icon_previews", None) + + row = layout if ui_context == "TOOL_HEADER" else layout.row(align=True) if icon_previews: custom_icon = icon_previews.get(text.upper().replace(" ", "_"), icon_previews["IFC"]).icon_id op = row.operator(operator_to_use, text=op_text, icon_value=custom_icon) @@ -646,6 +855,7 @@ class Blender(bonsai.core.tool.Blender): op = row.operator(operator_to_use, text=op_text) if ui_context != "TOOL_HEADER": row.label(text="", icon=modifier_icon) + row.separator(factor=1) row.label(text="", icon=f"EVENT_{key}") if operator_to_use == hotkey_operator: @@ -678,19 +888,57 @@ class Blender(bonsai.core.tool.Blender): # ( 1.0, 1.0, -1.0), # 7 # ] bound_box = obj.bound_box + min_pt = Vector(bound_box[0]) + max_pt = Vector(bound_box[6]) bbox_dict = { - "min_x": bound_box[0][0], - "max_x": bound_box[6][0], - "min_y": bound_box[0][1], - "max_y": bound_box[6][1], - "min_z": bound_box[0][2], - "max_z": bound_box[6][2], - "min_point": Vector(bound_box[0]), - "max_point": Vector(bound_box[6]), - "center": (Vector(bound_box[6]) + Vector(bound_box[0])) / 2, + "min_x": min_pt.x, + "max_x": max_pt.x, + "min_y": min_pt.y, + "max_y": max_pt.y, + "min_z": min_pt.z, + "max_z": max_pt.z, + "min_point": min_pt, + "max_point": max_pt, + "center": (max_pt + min_pt) / 2, + # Intrinsic per-axis size in object-local space. Distinct from + # ``obj.dimensions``, which folds object-level scale into its + # output; this is the raw mesh bbox extent. + "dimensions": (max_pt.x - min_pt.x, max_pt.y - min_pt.y, max_pt.z - min_pt.z), } return bbox_dict + @classmethod + def get_object_world_bounding_box(cls, obj: bpy.types.Object) -> dict[str, Union[float, Vector]]: + """Same shape as ``get_object_bounding_box`` but with ``matrix_world`` + applied — extents are computed across the 8 transformed corners, so + a rotated or scaled object reports its actual world-axis AABB rather + than the misleading transform of the local-space corners. + + ``bound_box[0]`` / ``bound_box[6]`` are the local min/max corners but + do NOT correspond to the world AABB extremes once the object is + rotated, so min/max must be taken per-axis across all 8 corners.""" + corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box] + xs = [c.x for c in corners] + ys = [c.y for c in corners] + zs = [c.z for c in corners] + min_point = Vector((min(xs), min(ys), min(zs))) + max_point = Vector((max(xs), max(ys), max(zs))) + return { + "min_x": min_point.x, + "max_x": max_point.x, + "min_y": min_point.y, + "max_y": max_point.y, + "min_z": min_point.z, + "max_z": max_point.z, + "min_point": min_point, + "max_point": max_point, + "center": (min_point + max_point) / 2, + # World-axis-aligned per-axis size. For rotated objects this is + # the AABB extent, not the intrinsic mesh size (use the local + # variant for that). + "dimensions": (max_point.x - min_point.x, max_point.y - min_point.y, max_point.z - min_point.z), + } + @classmethod def select_and_activate_single_object(cls, context: bpy.types.Context, active_object: bpy.types.Object) -> None: for obj in context.selected_objects: @@ -1137,20 +1385,18 @@ class Blender(bonsai.core.tool.Blender): :return: True if an action was taken, False otherwise """ - if cls.is_roof(element): - if cls.is_editing_roof_parameters(obj): - bpy.ops.bim.finish_editing_roof() + # roof and railing both finalize then drop into path-edit mode — handle + # them before the generic finish dispatch so the path transition runs. + if tool.Parametric.is_roof(element): + if tool.Parametric.ROOF.is_editing(obj): + tool.Parametric.run_bim_op(tool.Parametric.ROOF.finish_op) bpy.ops.bim.enable_editing_roof_path() - elif cls.is_railing(element): - if cls.is_editing_railing_parameters(obj): - bpy.ops.bim.finish_editing_railing() + elif tool.Parametric.is_railing(element): + if tool.Parametric.RAILING.is_editing(obj): + tool.Parametric.run_bim_op(tool.Parametric.RAILING.finish_op) bpy.ops.bim.enable_editing_railing_path() - elif cls.is_editing_stair_parameters(obj): - bpy.ops.bim.finish_editing_stair() - elif cls.is_editing_door_parameters(obj): - bpy.ops.bim.finish_editing_door() - elif cls.is_editing_window_parameters(obj): - bpy.ops.bim.finish_editing_window() + elif feature := tool.Parametric.is_object_editing(obj): + tool.Parametric.run_bim_op(feature.finish_op) else: return False return True @@ -1161,68 +1407,112 @@ class Blender(bonsai.core.tool.Blender): :return: True if an action was taken, False otherwise """ + # Path-edit modes are distinct from parametric draft modes; handle them first. if cls.is_editing_railing_path(obj): bpy.ops.bim.cancel_editing_railing_path() elif cls.is_editing_roof_path(obj): bpy.ops.bim.cancel_editing_roof_path() - elif cls.is_editing_railing_parameters(obj): - bpy.ops.bim.cancel_editing_railing() - elif cls.is_editing_door_parameters(obj): - bpy.ops.bim.cancel_editing_door() - elif cls.is_editing_window_parameters(obj): - bpy.ops.bim.cancel_editing_window() - elif cls.is_editing_roof_parameters(obj): - bpy.ops.bim.cancel_editing_roof() - elif cls.is_editing_stair_parameters(obj): - bpy.ops.bim.cancel_editing_stair() + elif feature := tool.Parametric.is_object_editing(obj): + tool.Parametric.run_bim_op(feature.cancel_op) else: return False return True @classmethod def is_eligible_for_railing_modifier(cls, obj: bpy.types.Object) -> bool: - return tool.Blender.is_object_an_ifc_class(obj, ("IfcRailing", "IfcRailingType")) + return tool.Blender.is_object_an_ifc_class(obj, _RAILING_MODIFIER_IFC_CLASSES) @classmethod def is_eligible_for_stair_modifier(cls, obj: bpy.types.Object) -> bool: - return tool.Blender.is_object_an_ifc_class( - obj, ("IfcStairFlight", "IfcStairFlightType", "IfcMember", "IfcMemberType", "IfcStair", "IfcStairType") - ) + return tool.Blender.is_object_an_ifc_class(obj, _STAIR_MODIFIER_IFC_CLASSES) @classmethod def is_eligible_for_window_modifier(cls, obj: bpy.types.Object) -> bool: - return tool.Blender.is_object_an_ifc_class(obj, ("IfcWindow", "IfcWindowType", "IfcWindowStyle")) + return tool.Blender.is_object_an_ifc_class(obj, _WINDOW_MODIFIER_IFC_CLASSES) @classmethod def is_eligible_for_door_modifier(cls, obj: bpy.types.Object) -> bool: - return tool.Blender.is_object_an_ifc_class(obj, ("IfcDoor", "IfcDoorType", "IfcDoorStyle")) + return tool.Blender.is_object_an_ifc_class(obj, _DOOR_MODIFIER_IFC_CLASSES) @classmethod def is_eligible_for_roof_modifier(cls, obj: bpy.types.Object) -> bool: - return tool.Blender.is_object_an_ifc_class(obj, ("IfcRoof", "IfcRoofType")) + return tool.Blender.is_object_an_ifc_class(obj, _ROOF_MODIFIER_IFC_CLASSES) @classmethod - def is_railing(cls, element: entity_instance) -> bool: - return tool.Pset.get_element_pset(element, "BBIM_Railing") + def is_array_child(cls, element: entity_instance) -> bool: + """True if element is a CHILD of a Bonsai parametric array. + + Children are managed replicas regenerated from the parent's pset — + their parametric attributes (door dimensions, wall lengths, …) are + overwritten on the next ``regenerate_array``. Parametric gizmo + groups skip children via this predicate in ``poll``. + + This sits on a different axis from ``tool.Parametric.is_array``: + cardinality (parent vs child) is orthogonal to feature kind, and + an arrayed wall fires both ``is_wall`` and ``is_array`` on the + same element.""" + if element is None: + return False + pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + if not pset: + return False + parent_guid = pset.get("Parent") + return parent_guid is not None and parent_guid != element.GlobalId @classmethod - def is_roof(cls, element: entity_instance) -> bool: - return tool.Pset.get_element_pset(element, "BBIM_Roof") + def any_selected_is_array_child(cls) -> bool: + """True if any selected IFC-linked object is a Bonsai array child. + + Multi-object wall topology gizmos (merge / join / extend / unjoin + / fillet) and their bound operators gate on this: any mutation + applied to a child is overwritten on the next + ``regenerate_array``, and merge specifically would leave the + parent's ``BBIM_Array.Data`` list pointing at a deleted GUID. + + Memoised against (selection signature, IFC geometry generation) + so gizmo polls that fire per input event don't re-walk the pset + for every selected object every frame. Identity-keyed so plain + Python objects (used by tests) work alongside real Blender + ``bpy_struct`` wrappers.""" + selected = tool.Blender.get_selected_objects() + selection_sig = frozenset(id(obj) for obj in selected) + current_gen = tool.Parametric.get_geom_generation() + cached = cls._any_selected_array_child_memo + if cached is not None and cached[0] == selection_sig and cached[1] == current_gen: + return cached[2] + result = False + for obj in selected: + element = tool.Ifc.get_entity(obj) + if element is not None and cls.is_array_child(element): + result = True + break + cls._any_selected_array_child_memo = (selection_sig, current_gen, result) + return result + + _any_selected_array_child_memo: tuple[frozenset[int], int, bool] | None = None @classmethod - def is_window(cls, element: entity_instance) -> bool: - return tool.Pset.get_element_pset(element, "BBIM_Window") + def is_slab(cls, element: entity_instance) -> bool: + """A slab is host-eligible for the parametric add-opening gizmo if + it is an IfcSlab with LAYER3 usage. + + Slabs carry no proprietary BBIM_Slab pset — their parametric state + lives in standard IFC (extrusion depth, IfcMaterialLayerSetUsage + with LayerSetDirection AXIS3). Any LAYER3 slab qualifies.""" + if element is None or not element.is_a("IfcSlab"): + return False + return tool.Model.get_usage_type(element) == "LAYER3" @classmethod - def is_door(cls, element: entity_instance) -> bool: - return tool.Pset.get_element_pset(element, "BBIM_Door") + def is_pipe_segment(cls, element: entity_instance) -> bool: + return element is not None and element.is_a("IfcPipeSegment") @classmethod - def is_stair(cls, element: entity_instance) -> bool: - return tool.Pset.get_element_pset(element, "BBIM_Stair") + def is_duct_segment(cls, element: entity_instance) -> bool: + return element is not None and element.is_a("IfcDuctSegment") @classmethod - def is_editing_railing_path(cls, obj: bpy.types.Object): + def is_editing_railing_path(cls, obj: bpy.types.Object) -> bool: props = tool.Model.get_railing_props(obj) return props.is_editing_path @@ -1231,107 +1521,10 @@ class Blender(bonsai.core.tool.Blender): props = tool.Model.get_roof_props(obj) return props.is_editing_path - @classmethod - def is_editing_railing_parameters(cls, obj: bpy.types.Object) -> bool: - props = tool.Model.get_railing_props(obj) - return props.is_editing - - @classmethod - def is_editing_roof_parameters(cls, obj: bpy.types.Object) -> bool: - props = tool.Model.get_roof_props(obj) - return props.is_editing - - @classmethod - def is_editing_window_parameters(cls, obj: bpy.types.Object) -> bool: - props = tool.Model.get_window_props(obj) - return props.is_editing - - @classmethod - def is_editing_door_parameters(cls, obj: bpy.types.Object) -> bool: - props = tool.Model.get_door_props(obj) - return props.is_editing - - @classmethod - def is_editing_stair_parameters(cls, obj: bpy.types.Object) -> bool: - props = tool.Model.get_stair_props(obj) - return props.is_editing - @classmethod def is_modifier_with_non_editable_path(cls, element: entity_instance) -> bool: - return cls.is_stair(element) or cls.is_door(element) or cls.is_window(element) - - class Array: - @classmethod - def bake_children_transform(cls, parent_element: entity_instance, item: int) -> None: - modifier_data = list(cls.get_modifiers_data(parent_element))[item] - children = cls.get_children_objects(modifier_data) - for child in children: - constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None) - if constraint: - with bpy.context.temp_override(object=child): - bpy.ops.constraint.apply(constraint=constraint.name, owner="OBJECT") - - @classmethod - def constrain_children_to_parent(cls, parent_element: ifcopenshell.entity_instance) -> None: - if not (parent_obj := tool.Ifc.get_object(parent_element)): - return # Filtered out, arrayed void, etc - assert isinstance(parent_obj, bpy.types.Object) - children = cls.get_all_children_objects(parent_element) - for child in children: - constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None) - if constraint: - child.constraints.remove(constraint) - constraint = child.constraints.new("CHILD_OF") - constraint.name = "BBIM_Array_CHILD_OF" - assert isinstance(constraint, bpy.types.ChildOfConstraint) - constraint.target = parent_obj - - @classmethod - def set_children_lock_state( - cls, parent_element: ifcopenshell.entity_instance, item: int, lock_state: bool = True - ) -> None: - modifier_data = list(cls.get_modifiers_data(parent_element))[item] - children = cls.get_children_objects(modifier_data) - for child_obj in children: - Blender.lock_transform(child_obj, lock_state) - - @classmethod - def remove_constraints(cls, parent_element: ifcopenshell.entity_instance) -> None: - children = cls.get_all_children_objects(parent_element) - for child in children: - constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None) - if constraint: - child.constraints.remove(constraint) - - @classmethod - def get_all_objects(cls, parent_element: ifcopenshell.entity_instance) -> list[bpy.types.Object]: - parent_obj = tool.Ifc.get_object(parent_element) - assert isinstance(parent_obj, bpy.types.Object) - children_objects = list(cls.get_all_children_objects(parent_element)) - array_objects = [parent_obj] + children_objects # We ensure the parent is at index 0 - return array_objects - - @classmethod - def get_all_children_objects( - cls, parent_element: ifcopenshell.entity_instance - ) -> Generator[bpy.types.Object, None, None]: - for array_modifier in cls.get_modifiers_data(parent_element): - yield from cls.get_children_objects(array_modifier) - - @classmethod - def get_modifiers_data( - cls, parent_element: ifcopenshell.entity_instance - ) -> Generator[dict[str, Any], None, None]: - array_pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array") - yield from json.loads(array_pset["Data"]) - - @classmethod - def get_children_objects(cls, modifier_data: dict[str, Any]) -> Generator[bpy.types.Object, None, None]: - child_guid: str - for child_guid in modifier_data["children"]: - child_obj = tool.Blender.get_object_from_guid(child_guid) - if child_obj: - yield child_obj + feature = tool.Parametric.find_for_element(element) + return bool(feature and feature.has_non_editable_path) class Attribute: @classmethod @@ -1835,6 +2028,18 @@ class Blender(bonsai.core.tool.Blender): dct = {cls.bl_idname: cls.ifc_element_type for cls in (BimTool.__subclasses__())} return types.MappingProxyType(dct) + @classmethod + @lru_cache + def get_property_header_tools(cls) -> frozenset[str]: + """``BimTool`` plus its parametric subclasses — the workspace + tools whose 3D-view / N-panel header surfaces BIM Tool property + floats (extrusion_depth, length, x_angle). ``AnnotationTool`` + and the non-``BimTool`` workspace tools (spatial / structural / + cad / covering) are excluded by construction.""" + from bonsai.bim.module.model.workspace import BimTool + + return frozenset(cls.bl_idname for cls in (BimTool.__subclasses__() + [BimTool])) + @classmethod def get_object_constraint_props(cls, obj: bpy.types.Object) -> BIMObjectConstraintProperties: return obj.BIMObjectConstraintProperties # pyright: ignore[reportAttributeAccessIssue] @@ -2055,6 +2260,65 @@ class Blender(bonsai.core.tool.Blender): return False return True + @classmethod + def draw_bmesh_face_tris( + cls, + bm: bmesh.types.BMesh, + world_vert_coords: list, + color: Any, + draw_batch: Callable[[str, list, Any, list], None], + ) -> None: + """Submit a non-mutating beauty-triangulated TRIS batch for ``bm``'s faces. + + ``world_vert_coords`` must be indexed by ``bm.verts`` index. Never call + ``bmesh.ops.triangulate`` on a live bmesh to compute draw indices — it + mutates the input and produces ear-clip fans that render as visible + streaks at low alpha. + """ + tris = [[loop.vert.index for loop in tri] for tri in bm.calc_loop_triangles()] + draw_batch("TRIS", world_vert_coords, color, tris) + + @classmethod + def build_dashed_line_segments( + cls, + world_verts: Sequence[Sequence[float]], + edges_indices: Sequence[Sequence[int]], + dash_period: float, + dash_width: float, + ) -> tuple[list[tuple[float, float, float]], list[tuple[int, int]]]: + """Pre-segment edges into world-space dash chunks for a vanilla LINES batch. + + Each input edge is sliced into segments of length ``dash_width`` spaced + ``dash_period`` apart (dash phase resets per-edge). The result is a fresh + ``(verts, edges)`` pair that draws as dashes through any standard line + shader — letting both passes of a visible/occluded outline reuse the + same shader so depth values match exactly across passes. + """ + new_verts: list[tuple[float, float, float]] = [] + new_edges: list[tuple[int, int]] = [] + if dash_period <= 0 or dash_width <= 0: + return new_verts, new_edges + n = len(world_verts) + for i, j in edges_indices: + if not (0 <= i < n and 0 <= j < n) or i == j: + continue + v0 = world_verts[i] + v1 = world_verts[j] + dx, dy, dz = v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2] + edge_length = math.sqrt(dx * dx + dy * dy + dz * dz) + if edge_length == 0.0: + continue + ux, uy, uz = dx / edge_length, dy / edge_length, dz / edge_length + t = 0.0 + while t < edge_length: + t_end = min(t + dash_width, edge_length) + idx = len(new_verts) + new_verts.append((v0[0] + ux * t, v0[1] + uy * t, v0[2] + uz * t)) + new_verts.append((v0[0] + ux * t_end, v0[1] + uy * t_end, v0[2] + uz * t_end)) + new_edges.append((idx, idx + 1)) + t += dash_period + return new_verts, new_edges + @classmethod def extract_error_reports(cls, exception: RuntimeError) -> list[str]: """Extracts error report lines from a runtime exception during operator execution. @@ -2205,7 +2469,7 @@ class Blender(bonsai.core.tool.Blender): See https://projects.blender.org/blender/blender/issues/149283 """ - if len(bytedata) == (n * 2): + if len(bytedata) == (n * 8): # float64 has 8 bytes per element return np.frombuffer(bytedata, dtype=np.float64).astype(np.float32) return np.frombuffer(bytedata, dtype=np.float32) diff --git a/src/bonsai/bonsai/tool/cad.py b/src/bonsai/bonsai/tool/cad.py index c91b5df0d8..957c8339fb 100644 --- a/src/bonsai/bonsai/tool/cad.py +++ b/src/bonsai/bonsai/tool/cad.py @@ -32,6 +32,7 @@ from __future__ import annotations import math import sys +from collections.abc import Sequence from typing import TYPE_CHECKING, Union import bmesh @@ -45,6 +46,13 @@ if TYPE_CHECKING: VTX_PRECISION = 1.0e-5 +# Tolerances below are in Blender units (SI metres). +# Looser than VTX_PRECISION because regen-time numeric drift exceeds CAD snap precision. +WELD_TOLERANCE = 1.0e-4 +# How close a vertex must be to the cut plane to count as on it. +BISECT_TOLERANCE = 1.0e-4 +# Strict weld for cleaning up exactly-coincident vertices. +WELD_EPSILON = 1.0e-6 class Cad: @@ -169,6 +177,14 @@ class Cad: return False return (x + tolerance) > value > (x - tolerance) + @classmethod + def is_multiple_of_pi(cls, value: float) -> bool: + """True when ``value`` is an integer multiple of π within tolerance — + the parallelism / anti-parallelism check rotation-difference logic + reaches for (segments aligned modulo a 180° flip).""" + n = round(value / math.pi) + return cls.is_x(abs(value - n * math.pi), 0) + @classmethod def normalise_angle(cls, angle: float) -> float: """Normalise an angle between -179 and 180""" @@ -996,3 +1012,106 @@ class Cad: y = height_half + height_half * (prj[1] / w) return Vector((float(x), float(y))) return default + + @classmethod + def sweep_disk_along_polyline( + cls, + bm: bmesh.types.BMesh, + points: Sequence[Vector], + radius: float, + arc_indices: Sequence[int] = (), + profile_segments: int = 8, + ) -> None: + """Append a tube of ``radius`` along the polyline ``points`` to ``bm``. + + Viewport-quality approximation of an IFC ``IfcSweptDiskSolid``: each + consecutive pair of points becomes a capped cylinder. The cylinders + overlap at joints rather than being mitered — the visual artifact is + negligible at typical handrail radii (~25mm) and acceptable for + live parametric-edit preview. + + ``arc_indices`` is accepted for API symmetry with the IFC builder + (which receives the same data structure), but is currently unused — + arcs are visualised as polyline kinks. Tessellating each arc with a + Lagrange or circular interpolation would smooth the joints; deferred + until profile fidelity becomes a concern. + + :param bm: target bmesh, mutated in place. + :param points: polyline vertices. + :param radius: tube radius (project units). + :param arc_indices: indices of arc midpoints (currently ignored). + :param profile_segments: sides on each cylinder cross-section. + """ + del arc_indices # accepted for forward compatibility; see docstring + if len(points) < 2: + return + for p0, p1 in zip(points, points[1:]): + cls._add_capped_cylinder(bm, Vector(p0), Vector(p1), radius, profile_segments) + + @classmethod + def add_disk_extrusion( + cls, + bm: bmesh.types.BMesh, + position: Vector, + radius: float, + depth: float, + axis_rotation_z: float, + profile_segments: int = 12, + ) -> None: + """Append a flat cylinder (disk extrusion) to ``bm``. + + A disk of ``radius`` extruded by ``depth`` along the +Y axis rotated + by ``axis_rotation_z`` radians around Z. ``position`` is the disk's + base, not its centre. + + :param bm: target bmesh, mutated in place. + :param position: base of the extrusion in object-local coordinates. + :param radius: disk radius. + :param depth: extrusion depth along the (rotated) Y axis. + :param axis_rotation_z: rotation around Z applied to the +Y axis to + obtain the extrusion direction. + :param profile_segments: sides on the disk's edge. + """ + # The +Y axis rotated by axis_rotation_z around Z gives the extrusion + # direction: (-sin(θ), cos(θ), 0). The disk axis points along it. + axis = Vector((-math.sin(axis_rotation_z), math.cos(axis_rotation_z), 0.0)) + end = position + axis * depth + cls._add_capped_cylinder(bm, position, end, radius, profile_segments) + + @classmethod + def _add_capped_cylinder( + cls, + bm: bmesh.types.BMesh, + p0: Vector, + p1: Vector, + radius: float, + segments: int, + ) -> None: + """Append one capped cylinder of ``radius`` from ``p0`` to ``p1`` to ``bm``.""" + direction = p1 - p0 + length = direction.length + if length < 1e-9: + return + direction = direction / length + + z_axis = Vector((0.0, 0.0, 1.0)) + dot = direction.dot(z_axis) + if dot > 1.0 - 1e-6: + rotation = Matrix.Identity(4) + elif dot < -1.0 + 1e-6: + # Anti-parallel: rotate 180° around X so the cone flips bottom-to-top. + rotation = Matrix.Rotation(math.pi, 4, "X") + else: + rotation = z_axis.rotation_difference(direction).to_matrix().to_4x4() + + matrix = Matrix.Translation((p0 + p1) * 0.5) @ rotation + bmesh.ops.create_cone( + bm, + cap_ends=True, + cap_tris=False, + segments=segments, + radius1=radius, + radius2=radius, + depth=length, + matrix=matrix, + ) diff --git a/src/bonsai/bonsai/tool/collector.py b/src/bonsai/bonsai/tool/collector.py index 1e6653acd1..5fac35b170 100644 --- a/src/bonsai/bonsai/tool/collector.py +++ b/src/bonsai/bonsai/tool/collector.py @@ -135,6 +135,7 @@ class Collector(bonsai.core.tool.Collector): if element.is_a("IfcFeatureElementSubtraction"): obj.display_type = "WIRE" + obj.display.show_shadows = False @classmethod def _create_project_child_collection(cls, name: str) -> bpy.types.Collection: diff --git a/src/bonsai/bonsai/tool/cost.py b/src/bonsai/bonsai/tool/cost.py index bbec525ee9..fc07a629a6 100644 --- a/src/bonsai/bonsai/tool/cost.py +++ b/src/bonsai/bonsai/tool/cost.py @@ -987,7 +987,8 @@ class Cost(bonsai.core.tool.Cost): def disable_editing_cost_item_parent(cls) -> None: props = cls.get_cost_props() props.active_cost_item_id = 0 - props.change_cost_item_parent = False + if props.change_cost_item_parent == True: + props.change_cost_item_parent = False @classmethod def load_cost_item_quantities(cls, cost_item: Optional[ifcopenshell.entity_instance] = None) -> None: diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index 86113b9c95..232e963359 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -1756,6 +1756,10 @@ class Drawing(bonsai.core.tool.Drawing): # For section/elevation views, elevate the segment vertically if not (points := helper.elevate_segment(bounds, [v1, v2])): return + elif target_view == "MODEL_VIEW": + # For model views, clip to XY bounds and keep Z (3D line at true elevation) + if not (points := helper.clip_segment(bounds, [v1, v2])): + return else: return diff --git a/src/bonsai/bonsai/tool/duplicate.py b/src/bonsai/bonsai/tool/duplicate.py new file mode 100644 index 0000000000..eb3630ccf1 --- /dev/null +++ b/src/bonsai/bonsai/tool/duplicate.py @@ -0,0 +1,328 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2021 Dion Moult +# +# 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 . + +# This file was generated with the assistance of an AI coding tool. + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal + +import bpy +import ifcopenshell +import ifcopenshell.util.element +import ifcopenshell.util.placement +import ifcopenshell.util.representation + +import bonsai.core.geometry +import bonsai.core.tool +import bonsai.tool as tool + + +@dataclass +class DecompositionRecord: + type: Literal["fill"] + element: ifcopenshell.entity_instance + + +@dataclass +class ConnectionRecord: + type: Literal["path"] + relating_element: ifcopenshell.entity_instance + related_element: ifcopenshell.entity_instance + relating_connection_type: str + related_connection_type: str + relating_priorities: list[int] + related_priorities: list[int] + + +@dataclass +class PortConnectionRecord: + relating_port_index: int + related_element: ifcopenshell.entity_instance + related_port_index: int + direction: str + + +@dataclass +class PortConnectionSnapshot: + """Port-to-port connections and per-element port counts captured before duplication.""" + + by_element: dict[ifcopenshell.entity_instance, list[PortConnectionRecord]] = field(default_factory=dict) + port_counts: dict[ifcopenshell.entity_instance, int] = field(default_factory=dict) + + +class Duplicate(bonsai.core.tool.Duplicate): + + _pending_warnings: list[str] = [] + + @classmethod + def _emit_warning(cls, message: str) -> None: + """Buffer a warning for later retrieval by an operator. Falling through + to a print keeps the message in the Blender console for the headless / + no-operator code path.""" + cls._pending_warnings.append(message) + print(f"Bonsai: WARNING — {message}") + + @classmethod + def consume_warnings(cls) -> list[str]: + """Return and clear the buffered warnings — operators call this after + ``tool.Geometry.duplicate_ifc_objects`` to forward each to ``self.report``.""" + warnings = cls._pending_warnings + cls._pending_warnings = [] + return warnings + + @classmethod + def get_decomposition_relationships( + cls, objs: list[bpy.types.Object] + ) -> dict[ifcopenshell.entity_instance, DecompositionRecord]: + relationships: dict[ifcopenshell.entity_instance, DecompositionRecord] = {} + for obj in objs: + element = tool.Ifc.get_entity(obj) + if not element: + continue + if building := tool.Spatial.get_host_element(element): + relationships[element] = DecompositionRecord(type="fill", element=building) + return relationships + + @classmethod + def get_connection_relationships( + cls, objs: list[bpy.types.Object] + ) -> dict[ifcopenshell.entity_instance, ConnectionRecord]: + relationships: dict[ifcopenshell.entity_instance, ConnectionRecord] = {} + for obj in objs: + element = tool.Ifc.get_entity(obj) + if not element: + continue + if hasattr(element, "ConnectedTo") and element.ConnectedTo: + paths = [ + connection for connection in element.ConnectedTo if connection.is_a("IfcRelConnectsPathElements") + ] + for path in paths: + relationships[element] = ConnectionRecord( + type="path", + relating_element=path.RelatingElement, + related_element=path.RelatedElement, + relating_connection_type=path.RelatingConnectionType, + related_connection_type=path.RelatedConnectionType, + relating_priorities=list(path.RelatingPriorities or []), + related_priorities=list(path.RelatedPriorities or []), + ) + return relationships + + @classmethod + def get_port_connection_relationships(cls, objs: list[bpy.types.Object]) -> PortConnectionSnapshot: + """Snapshot ``IfcRelConnectsPorts`` among MEP elements in ``objs``, indexed for positional-port replay onto duplicates.""" + # Function-local: top-level import would trigger a partial-init cycle. + from bonsai.tool.system import direction_from_port_pair + + snapshot = PortConnectionSnapshot() + elements_in_set: set[ifcopenshell.entity_instance] = set() + for obj in objs: + element = tool.Ifc.get_entity(obj) + if element is not None and tool.System.is_mep_element(element): + elements_in_set.add(element) + if not elements_in_set: + return snapshot + + ordered_elements = sorted(elements_in_set, key=lambda e: e.id()) + for element in ordered_elements: + snapshot.port_counts[element] = len(tool.System.get_ports(element)) + + seen: set[tuple[tuple[int, int], tuple[int, int]]] = set() + for element in ordered_elements: + ports = tool.System.get_ports(element) + for port_index, port in enumerate(ports): + connected_port = tool.System.get_connected_port(port) + if connected_port is None: + continue + other_element = tool.System.get_port_relating_element(connected_port) + if other_element is None or other_element not in elements_in_set: + continue + other_ports = tool.System.get_ports(other_element) + try: + other_port_index = other_ports.index(connected_port) + except ValueError: + continue + pair_key = tuple( + sorted( + [ + (element.id(), port_index), + (other_element.id(), other_port_index), + ] + ) + ) + if pair_key in seen: + continue + seen.add(pair_key) + + snapshot.by_element.setdefault(element, []).append( + PortConnectionRecord( + relating_port_index=port_index, + related_element=other_element, + related_port_index=other_port_index, + direction=direction_from_port_pair(port, connected_port), + ) + ) + return snapshot + + @classmethod + def recreate_decompositions( + cls, + relationships: dict[ifcopenshell.entity_instance, DecompositionRecord], + old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]], + ) -> None: + for subelement, data in relationships.items(): + new_subelements = old_to_new.get(subelement) + new_elements = old_to_new.get(data.element) + if not new_subelements or not new_elements: + continue + for i, new_subelement in enumerate(new_subelements): + new_element = new_elements[i] + if data.type == "fill": + element = new_element + filling = new_subelement + voided_obj = tool.Ifc.get_object(new_element) + filling_obj = tool.Ifc.get_object(new_subelement) + + existing_opening_occurrence = subelement.FillsVoids[0].RelatingOpeningElement + opening = tool.Ifc.run("root.copy_class", product=existing_opening_occurrence) + tool.Ifc.run( + "geometry.edit_object_placement", + product=opening, + matrix=ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement), + is_si=False, + ) + + representation = ifcopenshell.util.representation.get_representation( + existing_opening_occurrence, "Model", "Body", "MODEL_VIEW" + ) + representation = ifcopenshell.util.representation.resolve_representation(representation) + mapped_representation = tool.Ifc.run("geometry.map_representation", representation=representation) + tool.Ifc.run( + "geometry.assign_representation", + product=opening, + representation=mapped_representation, + ) + tool.Ifc.run("feature.add_feature", feature=opening, element=element) + tool.Ifc.run("feature.add_filling", opening=opening, element=filling) + + voided_objs = [voided_obj] + # Openings affect all subelements of an aggregate + for child_subelement in ifcopenshell.util.element.get_decomposition(element): + subobj = tool.Ifc.get_object(child_subelement) + if subobj: + voided_objs.append(subobj) + + for voided_obj in voided_objs: + if mesh_data := voided_obj.data: + representation = tool.Ifc.get().by_id( + tool.Geometry.get_mesh_props(mesh_data).ifc_definition_id + ) + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=voided_obj, + representation=representation, + ) + + @classmethod + def recreate_connections( + cls, + relationship: dict[ifcopenshell.entity_instance, ConnectionRecord], + old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]], + ) -> None: + for element, data in relationship.items(): + try: + new_relating_element = old_to_new.get(data.relating_element)[0] + new_related_element = old_to_new.get(data.related_element)[0] + except (KeyError, IndexError, TypeError): + continue + new_rel = tool.Ifc.run( + "geometry.connect_path", + relating_element=new_relating_element, + related_element=new_related_element, + relating_connection=data.relating_connection_type, + related_connection=data.related_connection_type, + ) + # connect_path hardcodes priorities to []; restore them post-hoc. + priority_attrs: dict[str, Any] = {} + if data.relating_priorities: + priority_attrs["RelatingPriorities"] = data.relating_priorities + if data.related_priorities: + priority_attrs["RelatedPriorities"] = data.related_priorities + if new_rel is not None and priority_attrs: + try: + tool.Ifc.run("attribute.edit_attributes", product=new_rel, attributes=priority_attrs) + except (RuntimeError, ifcopenshell.Error) as e: + cls._emit_warning( + f"connection priority restore failed for {new_rel}; " + f"duplicate has empty RelatingPriorities/RelatedPriorities: {e}" + ) + + @classmethod + def recreate_port_connections( + cls, + snapshot: PortConnectionSnapshot, + old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]], + ) -> None: + """Recreate ``IfcRelConnectsPorts`` between duplicates; skip records whose duplicate's port count diverges from the snapshot.""" + for relating_element, records in snapshot.by_element.items(): + for record in records: + related_element = record.related_element + try: + new_relating = old_to_new[relating_element][0] + new_related = old_to_new[related_element][0] + except (KeyError, IndexError): + continue + + new_relating_ports = tool.System.get_ports(new_relating) + new_related_ports = tool.System.get_ports(new_related) + + expected_relating = snapshot.port_counts.get(relating_element) + if expected_relating is not None and len(new_relating_ports) != expected_relating: + cls._emit_warning( + f"port reconnect skipped — duplicate has {len(new_relating_ports)} ports, " + f"snapshot had {expected_relating}" + ) + continue + expected_related = snapshot.port_counts.get(related_element) + if expected_related is not None and len(new_related_ports) != expected_related: + cls._emit_warning( + f"port reconnect skipped — duplicate has {len(new_related_ports)} ports, " + f"snapshot had {expected_related}" + ) + continue + + try: + new_port_a = new_relating_ports[record.relating_port_index] + new_port_b = new_related_ports[record.related_port_index] + except IndexError: + cls._emit_warning( + f"port reconnect skipped — record references port index past the duplicate's port list" + ) + continue + try: + tool.Ifc.run( + "system.connect_port", + port1=new_port_a, + port2=new_port_b, + direction=record.direction or "NOTDEFINED", + ) + except (RuntimeError, ifcopenshell.Error) as e: + cls._emit_warning(f"port reconnect failed between duplicates: {e}") diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index dce6c7a419..72a32d54a4 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -108,6 +108,28 @@ class Geometry(bonsai.core.tool.Geometry): raise Exception("user_remap is not supported for meshes in EDIT mode") old_data.user_remap(new_data) + @classmethod + def has_axis_representation(cls, element: ifcopenshell.entity_instance) -> bool: + """True if the element carries a shape representation whose + RepresentationIdentifier is 'Axis'. Elements without one cannot be + projected to an unambiguous 1D path; callers that draw schematic axis + overlays must skip them rather than fall back to mesh-derived geometry.""" + product_rep = getattr(element, "Representation", None) + if product_rep is None: + return False + for rep in product_rep.Representations: + if getattr(rep, "RepresentationIdentifier", None) == "Axis": + return True + return False + + @classmethod + def get_body_representation(cls, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None: + """The element's ``Model/Body/MODEL_VIEW`` representation, or ``None``. + Single source for the ``(context, identifier, target_view)`` triple used + by every body-geometry reader across walls, slabs, doors, openings, and + feature decorators.""" + return ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + @classmethod def clear_modifiers(cls, obj: bpy.types.Object) -> None: for modifier in obj.modifiers: @@ -214,7 +236,13 @@ class Geometry(bonsai.core.tool.Geometry): break mesh = obj.data assert isinstance(mesh, bpy.types.Mesh) - item = tool.Ifc.get().by_id(tool.Geometry.get_mesh_props(mesh).ifc_definition_id) + item_id = tool.Geometry.get_mesh_props(mesh).ifc_definition_id + try: + item = tool.Ifc.get().by_id(item_id) + except RuntimeError: + # Entity already deleted (e.g. removed as part of a sibling boolean collapse). + bpy.data.objects.remove(obj) + return rep_obj = props.representation_obj assert (rep_obj := props.representation_obj) and (rep_element := tool.Ifc.get_entity(rep_obj)) cls.remove_representation_item(item, rep_element) @@ -378,6 +406,29 @@ class Geometry(bonsai.core.tool.Geometry): bm.free() del mesh["ios_edges"] + @classmethod + def get_dissolved_edges( + cls, + mesh: bpy.types.Mesh, + angle_limit: float = radians(1.0), + ) -> tuple[list[Vector], list[tuple[int, int]]]: + # Read-only on `mesh`: builds a throwaway bmesh, dissolves coplanar + # edges while preserving material seams, returns wire-overlay data. + bm = bmesh.new() + bm.from_mesh(mesh) + bmesh.ops.dissolve_limit( + bm, + angle_limit=angle_limit, + verts=bm.verts, + edges=bm.edges, + delimit={"MATERIAL"}, + ) + bm.verts.index_update() + verts = [v.co.copy() for v in bm.verts] + edges = [(e.verts[0].index, e.verts[1].index) for e in bm.edges] + bm.free() + return verts, edges + @classmethod def apply_item_ids_as_vertex_groups(cls, obj: bpy.types.Object) -> None: """Save mesh-object item_ids as vertex groups in format 'ios_item_id_xxxx'. @@ -1081,11 +1132,16 @@ class Geometry(bonsai.core.tool.Geometry): @classmethod def get_representation_item(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]: data = obj.data - if ( - isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES) - and (ifc_id := tool.Geometry.get_mesh_props(data).ifc_definition_id) - and ((item := tool.Ifc.get().by_id(ifc_id)).is_a("IfcRepresentationItem")) - ): + if not isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES): + return None + ifc_id = tool.Geometry.get_mesh_props(data).ifc_definition_id + if not ifc_id: + return None + try: + item = tool.Ifc.get().by_id(ifc_id) + except RuntimeError: + return None + if item.is_a("IfcRepresentationItem"): return item return None @@ -1142,6 +1198,53 @@ class Geometry(bonsai.core.tool.Geometry): props.location_checksum = repr(tool.Blender.np_array_legacy(obj.matrix_world.translation).tobytes()) props.rotation_checksum = repr(tool.Blender.np_array_legacy(obj.matrix_world.to_3x3()).tobytes()) + @classmethod + def commit_placement_if_moved(cls, obj: bpy.types.Object, *, apply_scale: bool = True) -> None: + """Write ``obj.matrix_world`` back to its IFC ``ObjectPlacement`` when the + object has drifted since its last placement commit. + + Scope: drop-in only when the gate is exactly ``is_moved(obj)``. Call sites + whose gate is wider (e.g. ``is_moved OR is_scaled``) or already enforced + upstream (inside an ``if is_moved:`` block) should call + ``edit_object_placement`` directly to avoid the redundant inner check.""" + if not tool.Ifc.is_moved(obj): + return + bonsai.core.geometry.edit_object_placement( + tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj, apply_scale=apply_scale + ) + + @classmethod + def restore_placement_from_ifc(cls, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None: + """Snap ``obj.matrix_world`` back to ``element``'s committed IFC placement, + then re-baseline the drift checksum so ``tool.Ifc.is_moved(obj)`` returns + False afterwards. + + Precondition: ``element.ObjectPlacement`` must not be None. Callers in a + cancel-style flow that want a "restore-or-clear-drift" semantic must gate + on ObjectPlacement themselves and call ``record_object_position`` directly + in the no-placement branch.""" + assert element.ObjectPlacement is not None, ( + "restore_placement_from_ifc requires ObjectPlacement — gate the caller " + "or use restore_or_rebaseline_placement for the restore-or-clear-drift semantic" + ) + matrix_np = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement).copy() + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + matrix_np[:3, 3] *= unit_scale + obj.matrix_world = tool.Loader.apply_blender_offset_to_matrix_world(obj, matrix_np) + cls.record_object_position(obj) + + @classmethod + def restore_or_rebaseline_placement(cls, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None: + """Cancel-flow placement restore: revert ``obj.matrix_world`` to the committed + IFC placement; when the element has no ObjectPlacement, re-baseline the drift + checksum instead so a subsequent edit does not silently commit the discarded drag.""" + if not tool.Ifc.is_moved(obj): + return + if element.ObjectPlacement is None: + cls.record_object_position(obj) + return + cls.restore_placement_from_ifc(obj, element) + @classmethod def remove_connection(cls, connection: ifcopenshell.entity_instance) -> None: tool.Ifc.get().remove(connection) @@ -1193,11 +1296,27 @@ class Geometry(bonsai.core.tool.Geometry): bpy.data.objects.remove(obj) return new_obj + @classmethod + def detach_representation(cls, product: ifcopenshell.entity_instance) -> None: + """Replace ``product.Representation`` with a deep copy so the product + no longer shares its representation tree (mapped or direct) with any + other entity. The ``IfcGeometricRepresentationContext`` is excluded + from the copy so contexts stay file-singletons. No-op when the + product has no ``Representation`` attribute or it is unset.""" + rep = getattr(product, "Representation", None) + if rep is None: + return + product.Representation = ifcopenshell.util.element.copy_deep( + tool.Ifc.get(), rep, exclude=["IfcGeometricRepresentationContext"] + ) + @classmethod def resolve_mapped_representation( cls, representation: ifcopenshell.entity_instance ) -> ifcopenshell.entity_instance: if representation.RepresentationType == "MappedRepresentation": + if not representation.Items: + return representation return cls.resolve_mapped_representation(representation.Items[0].MappingSource.MappedRepresentation) return representation @@ -2120,8 +2239,11 @@ class Geometry(bonsai.core.tool.Geometry): new_active_obj = None # Track decompositions so they can be recreated after the operation - decomposition_relationships = tool.Root.get_decomposition_relationships(objects_to_duplicate) - connection_relationships = tool.Root.get_connection_relationships(objects_to_duplicate) + decomposition_relationships = tool.Duplicate.get_decomposition_relationships(objects_to_duplicate) + connection_relationships = tool.Duplicate.get_connection_relationships(objects_to_duplicate) + # Snapshot port-to-port connections — copy_class disconnects new ports + # by default, leaving Shift+D duplicates unconnected. + port_connection_snapshot = tool.Duplicate.get_port_connection_relationships(objects_to_duplicate) old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]] = {} old_obj_name_to_new_obj_name: dict[str, str] = {} @@ -2143,10 +2265,7 @@ class Geometry(bonsai.core.tool.Geometry): keep_data_linked = linked and not element and not is_tracked_opening # Prior to duplicating, sync the object placement to make decomposition recreation more stable. - if tool.Ifc.is_moved(obj): - bonsai.core.geometry.edit_object_placement( - tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj, apply_scale=False - ) + cls.commit_placement_if_moved(obj, apply_scale=False) new_obj = obj.copy() temp_data = None @@ -2200,7 +2319,7 @@ class Geometry(bonsai.core.tool.Geometry): array_data = arrays_to_duplicate.get(obj, None) tool.Model.handle_array_on_copied_element(new, array_data) if array_data: - for child in tool.Blender.Modifier.Array.get_all_children_objects(new): + for child in tool.Array.get_all_children_objects(new): child.select_set(True) # TODO: add new array children to recreate their decomposition too @@ -2228,10 +2347,11 @@ class Geometry(bonsai.core.tool.Geometry): # Remove connections with old objects and recreates paths cls.remove_old_connections(old_to_new) - tool.Root.recreate_connections(connection_relationships, old_to_new) + tool.Duplicate.recreate_connections(connection_relationships, old_to_new) + tool.Duplicate.recreate_port_connections(port_connection_snapshot, old_to_new) # Recreate decompositions - tool.Root.recreate_decompositions(decomposition_relationships, old_to_new) + tool.Duplicate.recreate_decompositions(decomposition_relationships, old_to_new) cls.remove_linked_aggregate_data(old_to_new) bonsai.bim.handler.refresh_ui_data() tool.Root.reload_grid_decorator() @@ -2296,8 +2416,8 @@ class Geometry(bonsai.core.tool.Geometry): continue array_data = [] - for modifier_data in tool.Blender.Modifier.Array.get_modifiers_data(array_parent): - children = set(tool.Blender.Modifier.Array.get_children_objects(modifier_data)) + for modifier_data in tool.Array.get_modifiers_data(array_parent): + children = set(tool.Array.get_children_objects(modifier_data)) if children.issubset(selected_objects): modifier_data["children"] = [] array_data.append(modifier_data) diff --git a/src/bonsai/bonsai/tool/loader.py b/src/bonsai/bonsai/tool/loader.py index 6d30192485..d738cc39c0 100644 --- a/src/bonsai/bonsai/tool/loader.py +++ b/src/bonsai/bonsai/tool/loader.py @@ -1216,7 +1216,7 @@ class Loader(bonsai.core.tool.Loader): ) -> bool: items = [i["item"] for i in ifcopenshell.util.representation.resolve_items(representation)] if len(items) == 1 and items[0].is_a("IfcSweptDiskSolid"): - if tool.Blender.Modifier.is_railing(element): + if tool.Parametric.is_railing(element): return False return True elif len(items) and ( # See #2508 why we accommodate for invalid IFCs here @@ -1224,7 +1224,7 @@ class Loader(bonsai.core.tool.Loader): and len({i.is_a() for i in items}) == 1 and len({i.Radius for i in items}) == 1 ): - if tool.Blender.Modifier.is_railing(element): + if tool.Parametric.is_railing(element): return False return True return False diff --git a/src/bonsai/bonsai/tool/misc.py b/src/bonsai/bonsai/tool/misc.py index 5676e8f76c..7a27268f6a 100644 --- a/src/bonsai/bonsai/tool/misc.py +++ b/src/bonsai/bonsai/tool/misc.py @@ -227,10 +227,8 @@ class Misc(bonsai.core.tool.Misc): @classmethod def set_object_origin_to_bottom(cls, obj: bpy.types.Object) -> None: - absolute_bound_box = [obj.matrix_world @ Vector(c) for c in obj.bound_box] - min_z = min([c[2] for c in absolute_bound_box]) new_origin = obj.matrix_world.translation.copy() - new_origin[2] = min_z + new_origin[2] = tool.Blender.get_object_world_bounding_box(obj)["min_z"] assert isinstance(obj.data, bpy.types.Mesh) obj.data.transform( Matrix.Translation( @@ -249,11 +247,8 @@ class Misc(bonsai.core.tool.Misc): @classmethod def scale_object_to_height(cls, obj: bpy.types.Object, height: float) -> None: - absolute_bound_box = [obj.matrix_world @ Vector(c) for c in obj.bound_box] - max_z = max([c[2] for c in absolute_bound_box]) - min_z = min([c[2] for c in absolute_bound_box]) - current_absolute_height = max_z - min_z - scale_factor = height / current_absolute_height + bbox = tool.Blender.get_object_world_bounding_box(obj) + scale_factor = height / (bbox["max_z"] - bbox["min_z"]) obj.matrix_world @= Matrix.Scale( scale_factor, 4, obj.matrix_world.inverted().to_quaternion() @ Vector((0, 0, 1)) ) diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index 60f79cc26e..2ea1678479 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -15,12 +15,14 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# This file was modified with the assistance of an AI coding tool. from __future__ import annotations import collections.abc import json -from collections.abc import Iterable, Sequence +from collections.abc import Callable, Iterable, Sequence from copy import deepcopy from math import atan, cos, degrees, pi, radians from typing import ( @@ -37,9 +39,11 @@ from typing import ( import bmesh import bpy import ifcopenshell +import ifcopenshell.api.feature import ifcopenshell.api.geometry import ifcopenshell.api.grid import ifcopenshell.api.pset +import ifcopenshell.api.root import ifcopenshell.geom import ifcopenshell.ifcopenshell_wrapper as W import ifcopenshell.util.element @@ -58,6 +62,7 @@ import bonsai.core.geometry import bonsai.core.tool import bonsai.tool as tool from bonsai.bim import import_ifc +from bonsai.tool.cad import VTX_PRECISION, WELD_TOLERANCE T = TypeVar("T") V_ = tool.Blender.V_ @@ -70,13 +75,16 @@ if TYPE_CHECKING: from bonsai.bim.module.model.prop import ( BIMArrayProperties, BIMDoorProperties, + BIMDuctSegmentProperties, BIMExternalParametricGeometryProperties, BIMModelProperties, + BIMPipeSegmentProperties, BIMPolylineProperties, BIMRailingProperties, BIMRoofProperties, BIMStairProperties, BIMSverchokProperties, + BIMWallProperties, BIMWindowProperties, ) @@ -98,6 +106,10 @@ class Model(bonsai.core.tool.Model): def get_stair_props(cls, obj: bpy.types.Object) -> BIMStairProperties: return obj.BIMStairProperties # pyright: ignore[reportAttributeAccessIssue] + @classmethod + def get_wall_props(cls, obj: bpy.types.Object) -> BIMWallProperties: + return obj.BIMWallProperties # pyright: ignore[reportAttributeAccessIssue] + @classmethod def get_roof_props(cls, obj: bpy.types.Object) -> BIMRoofProperties: return obj.BIMRoofProperties # pyright: ignore[reportAttributeAccessIssue] @@ -106,6 +118,14 @@ class Model(bonsai.core.tool.Model): def get_railing_props(cls, obj: bpy.types.Object) -> BIMRailingProperties: return obj.BIMRailingProperties # pyright: ignore[reportAttributeAccessIssue] + @classmethod + def get_pipe_segment_props(cls, obj: bpy.types.Object) -> BIMPipeSegmentProperties: + return obj.BIMPipeSegmentProperties # pyright: ignore[reportAttributeAccessIssue] + + @classmethod + def get_duct_segment_props(cls, obj: bpy.types.Object) -> BIMDuctSegmentProperties: + return obj.BIMDuctSegmentProperties # pyright: ignore[reportAttributeAccessIssue] + @classmethod def get_sverchok_props(cls, obj: bpy.types.Object) -> BIMSverchokProperties: return obj.BIMSverchokProperties # pyright: ignore[reportAttributeAccessIssue] @@ -123,6 +143,35 @@ class Model(bonsai.core.tool.Model): assert (scene := bpy.context.scene) return scene.BIMPolylineProperties # pyright: ignore[reportAttributeAccessIssue] + @classmethod + def resolve_active_props_for_edit( + cls, + context: bpy.types.Context, + props_getter: Callable[[bpy.types.Object], Any], + *, + subtype: Optional[tuple[str, Any]] = None, + ) -> Optional[tuple[bpy.types.Object, Any]]: + """Resolve ``(obj, props)`` for an operator that acts on the active + object only while a parametric edit is active. + + Returns ``None`` (the operator should ``return {"CANCELLED"}``) when + any of these fail: + - no active object, + - ``props.is_editing`` is False, + - ``subtype`` is given as ``(attr, value)`` and ``props. != value``. + """ + obj = context.active_object + if not obj: + return None + props = props_getter(obj) + if not getattr(props, "is_editing", False): + return None + if subtype is not None: + attr, value = subtype + if getattr(props, attr, None) != value: + return None + return obj, props + @classmethod def convert_si_to_unit(cls, value: T) -> T: if isinstance(value, (tuple, list)): @@ -312,6 +361,8 @@ class Model(bonsai.core.tool.Model): @classmethod def get_extrusion(cls, representation: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]: """Return first found IfcExtrudedAreaSolid""" + if not representation.Items: + return None item = representation.Items[0] while True: if item.is_a("IfcExtrudedAreaSolid"): @@ -321,6 +372,28 @@ class Model(bonsai.core.tool.Model): else: break + @classmethod + def get_sibling_occurrence_count(cls, element: ifcopenshell.entity_instance) -> int: + """Number of *other* products sharing this element's body representation. + + Returns the count of products bound to the same resolved body rep, minus + ``element`` itself and minus its type (if any). Zero when the element has + no body rep, no resolved rep, or no siblings. A non-zero result means a + parametric edit on ``element`` will silently mutate other instances' + geometry.""" + body_rep = tool.Geometry.get_body_representation(element) + if not body_rep: + return 0 + resolved = ifcopenshell.util.representation.resolve_representation(body_rep) + if not resolved: + return 0 + elements = tool.Geometry.get_elements_by_representation(resolved) + elements.discard(element) + element_type = ifcopenshell.util.element.get_type(element) + if element_type is not None: + elements.discard(element_type) + return len(elements) + unit_scale: float vertices: list[Vector] edges: list[Sequence[int]] @@ -792,7 +865,7 @@ class Model(bonsai.core.tool.Model): assert element or representation, "Either element or representation must be provided." if representation is None: assert element - representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + representation = tool.Geometry.get_body_representation(element) if not representation: return [] booleans = [] @@ -804,6 +877,57 @@ class Model(bonsai.core.tool.Model): items.append(item.FirstOperand) return booleans + @classmethod + def get_connected_slab_objs(cls, wall: ifcopenshell.entity_instance) -> list[bpy.types.Object]: + """Return Blender objects for slabs connected to wall via IfcRelConnectsElements(TOP).""" + result = [] + for rel in wall.ConnectedFrom: + if rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP": + slab_obj = tool.Ifc.get_object(rel.RelatingElement) + if slab_obj: + result.append(slab_obj) + return result + + @classmethod + def get_connected_wall_objs(cls, slab: ifcopenshell.entity_instance) -> list[bpy.types.Object]: + """Return Blender objects for LAYER2 walls connected to slab via IfcRelConnectsElements(TOP).""" + result = [] + for rel in slab.ConnectedTo: + if rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP": + wall_obj = tool.Ifc.get_object(rel.RelatedElement) + if wall_obj: + result.append(wall_obj) + return result + + @classmethod + def has_underside_connection(cls, element: ifcopenshell.entity_instance) -> bool: + """Return True if element has an IfcRelConnectsElements(TOP) relationship.""" + return any(rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP" for rel in element.ConnectedFrom) + + @classmethod + def remove_wall_to_underside_booleans(cls, wall: ifcopenshell.entity_instance) -> None: + """Remove all IfcBooleanResult items previously added by extend_walls_to_underside.""" + manual_booleans = cls.get_manual_booleans(wall) + if not manual_booleans: + return + ifc_file = tool.Ifc.get() + for b in manual_booleans: + sec = b.SecondOperand + if sec is None: + # The IfcPolygonalFaceSet was already deleted externally. Splice the + # orphaned IfcBooleanResult out of the chain so the representation stays valid. + parents = list(ifc_file.get_inverse(b)) + for parent in parents: + if parent.is_a("IfcBooleanResult") and parent.FirstOperand == b: + parent.FirstOperand = b.FirstOperand + elif parent.is_a("IfcShapeRepresentation"): + new_items = tuple((set(parent.Items) - {b}) | {b.FirstOperand}) + parent.Items = new_items + cls.unmark_manual_booleans(wall, [b.id()]) + ifc_file.remove(b) + elif sec.is_a("IfcTessellatedFaceSet"): + tool.Geometry.remove_representation_item(sec, wall) + @classmethod def get_manual_booleans( cls, element: ifcopenshell.entity_instance, representation: Optional[ifcopenshell.entity_instance] = None @@ -813,10 +937,11 @@ class Model(bonsai.core.tool.Model): return [] boolean_ids = json.loads(pset["Data"]) if representation is None: - representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + representation = tool.Geometry.get_body_representation(element) if not representation: return [] - booleans = [b for b in cls.get_booleans(element, representation) if b.id() in boolean_ids] + all_chain_booleans = cls.get_booleans(element, representation) + booleans = [b for b in all_chain_booleans if b.id() in boolean_ids] return booleans @classmethod @@ -902,7 +1027,7 @@ class Model(bonsai.core.tool.Model): # Revolved area check should happen inside bim.enable_editing_extrusion_axis # but keep it here to trigger import_representation_items, # so users will be able to at least move IfcRevolvedAreaSolid, until there will be a full support. - body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + body = tool.Geometry.get_body_representation(element) if body and any( i.is_a("IfcRevolvedAreaSolid") for i in ifcopenshell.util.representation.resolve_base_items(body) ): @@ -1015,7 +1140,14 @@ class Model(bonsai.core.tool.Model): def handle_array_on_copied_element( cls, element: ifcopenshell.entity_instance, array_data: Optional[dict[str, Any]] = None ) -> None: - """if no `array_data` is provided then an array will be removed from the element""" + """Post-copy hook: decide what to do with the BBIM_Array pset a copy + inherits from its source. + + - ``array_data=None`` — detach the copy from any array. Removes the + inherited BBIM_Array pset and any CHILD_OF constraint. + - ``array_data`` provided — promote the copy to a fresh array parent + with an empty children list, using the provided layer config. + """ if array_data is None: array_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") @@ -1059,8 +1191,8 @@ class Model(bonsai.core.tool.Model): ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=array_pset, properties={"Data": json_data}) for i in range(len(array_data)): - tool.Blender.Modifier.Array.set_children_lock_state(element, i, True) - tool.Blender.Modifier.Array.constrain_children_to_parent(element) + tool.Array.set_children_lock_state(element, i, True) + tool.Array.constrain_children_to_parent(element) @classmethod def regenerate_array( @@ -1097,12 +1229,17 @@ class Model(bonsai.core.tool.Model): offset = base_offset * i for obj in obj_stack: + # IndexError when child_i is past the recorded children list + # (count grew); RuntimeError when by_guid finds no entity (the + # child was deleted outside the array op); AssertionError when + # the IFC entity exists but its Blender object was unlinked. + # All three fall through to duplication. try: global_id = array["children"][child_i] child_element = tool.Ifc.get().by_guid(global_id) child_obj = tool.Ifc.get_object(child_element) assert child_obj - except: + except (IndexError, RuntimeError, AssertionError): old_to_new, _ = tool.Geometry.duplicate_ifc_objects([parent_obj]) child_element = next(iter(old_to_new.values()))[0] child_obj = tool.Ifc.get_object(child_element) @@ -1139,14 +1276,24 @@ class Model(bonsai.core.tool.Model): removed_children = set(existing_children) - set(array["children"]) for removed_child in removed_children: element = tool.Ifc.get().by_guid(removed_child) + # Strip any wall/slab opening cut by this child before deletion, + # so the host's HasOpenings shrinks symmetrically with count. + if getattr(element, "FillsVoids", None): + ifcopenshell.api.feature.remove_feature( + tool.Ifc.get(), feature=element.FillsVoids[0].RelatingOpeningElement + ) obj = tool.Ifc.get_object(element) if obj: tool.Geometry.delete_ifc_object(obj) + if array.get("per_child_opening", array.get("mirror_to_host", True)) and children_elements: + cls.mirror_parent_void_fillings_to_children(parent_element, children_elements) + if array_i in array_layers_to_apply: for child_element in children_elements: pset = tool.Pset.get_element_pset(child_element, "BBIM_Array") ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=child_element, pset=pset) + cls.unshare_opening_representation(child_element) array["children"] = [] array["count"] = 1 @@ -1159,6 +1306,112 @@ class Model(bonsai.core.tool.Model): tool.Ifc.get(), pset=pset, properties={"Data": json_data, "Parent": parent_element.GlobalId} ) + # Post-condition: parent is selected on return. duplicate_ifc_objects + # deselects the source on every call inside the regen loop; without + # this restore, callers get a deselected parent for arrays with N >= 2. + # TODO: batch the per-child duplicate_ifc_objects([parent]) calls into + # a single N-way duplicate — N depsgraph churns + N select/deselect + # flips is wasteful, and a batched duplicate would also remove the + # need for this restore. + parent_obj.select_set(True) + + @classmethod + def mirror_parent_void_fillings_to_children( + cls, + parent_element: ifcopenshell.entity_instance, + children_elements: Sequence[ifcopenshell.entity_instance], + ) -> None: + """Replicate the parent's FillsVoids → host chain onto each array child. + + For each child, tears down any stale opening, creates a new + IfcOpeningElement at the child's current placement, reuses the parent's + opening representation as a MappedRepresentation, and adds the + void + filling pair so the host element is cut once per child. + + No-op when the parent is not a filling, when the host element cannot + be resolved, or when the children list is empty. Opt out via the + per-layer ``per_child_opening`` flag on ``BBIM_Array.Data`` (legacy + key ``mirror_to_host`` still honoured for round-trip with older files). + """ + host = tool.Spatial.get_host_element(parent_element) + if host is None or not children_elements: + return + + ifc_file = tool.Ifc.get() + parent_opening = parent_element.FillsVoids[0].RelatingOpeningElement + parent_opening_rep = ifcopenshell.util.representation.get_representation( + parent_opening, "Model", "Body", "MODEL_VIEW" + ) + if parent_opening_rep is None: + return + parent_opening_rep = ifcopenshell.util.representation.resolve_representation(parent_opening_rep) + + for child in children_elements: + if getattr(child, "FillsVoids", None): + ifcopenshell.api.feature.remove_feature(ifc_file, feature=child.FillsVoids[0].RelatingOpeningElement) + child_obj = tool.Ifc.get_object(child) + if child_obj is None: + continue + + new_opening = ifcopenshell.api.root.create_entity( + ifc_file, + ifc_class="IfcOpeningElement", + predefined_type="OPENING", + name="Opening", + ) + ifcopenshell.api.geometry.edit_object_placement( + ifc_file, + product=new_opening, + matrix=np.array(child_obj.matrix_world), + is_si=True, + ) + mapped_representation = ifcopenshell.api.geometry.map_representation( + ifc_file, representation=parent_opening_rep + ) + ifcopenshell.api.geometry.assign_representation( + ifc_file, product=new_opening, representation=mapped_representation + ) + ifcopenshell.api.feature.add_feature(ifc_file, feature=new_opening, element=host) + ifcopenshell.api.feature.add_filling(ifc_file, opening=new_opening, element=child) + + # Openings affect every sub-element of an aggregate, not just the named host. + voided_objs: list[bpy.types.Object] = [] + host_obj = tool.Ifc.get_object(host) + if host_obj is not None: + voided_objs.append(host_obj) + for subelement in tool.Aggregate.get_parts_recursively(host): + subobj = tool.Ifc.get_object(subelement) + if subobj is not None: + voided_objs.append(subobj) + + for voided_obj in voided_objs: + if not voided_obj.data: + continue + voided_element = tool.Ifc.get_entity(voided_obj) + if voided_element is None: + continue + context = tool.Geometry.get_active_representation_context(voided_obj) + representation = tool.Geometry.get_representation_by_context(voided_element, context) + if representation is None: + continue + bonsai.core.geometry.switch_representation( + tool.Ifc, tool.Geometry, obj=voided_obj, representation=representation + ) + + @classmethod + def unshare_opening_representation(cls, filling: ifcopenshell.entity_instance) -> None: + """Detach a filling's opening representation from any shared mapped body. + + Required when a Bonsai array child is promoted to an independent + object: the array's per-child opening mirror builds each child's + opening representation as an ``IfcMappedRepresentation`` over the + parent opening's body. Without this detach, a later edit replacing + the parent body rewrites the shared ``IfcRepresentationMap`` and + reshapes the former-child's opening too.""" + if not getattr(filling, "FillsVoids", None): + return + tool.Geometry.detach_representation(filling.FillsVoids[0].RelatingOpeningElement) + @classmethod def replace_object_ifc_representation( cls, @@ -1305,8 +1558,8 @@ class Model(bonsai.core.tool.Model): return [obj for obj in tool.Blender.get_selected_objects() if tool.Ifc.get_entity(obj)] @classmethod - def has_selected_ifc_objects(cls) -> bool: - return any(tool.Ifc.get_entity(obj) for obj in tool.Blender.get_selected_objects()) + def has_selected_ifc_objects(cls, include_active: bool = True) -> bool: + return any(tool.Ifc.get_entity(obj) for obj in tool.Blender.get_selected_objects(include_active=include_active)) @classmethod def get_selected_mesh_objects(cls) -> list[bpy.types.Object]: @@ -1335,8 +1588,7 @@ class Model(bonsai.core.tool.Model): element = tool.Ifc.get_entity(object) if not element: return - psets = ifcopenshell.util.element.get_psets(element) - pset_data = psets.get(pset_name, None) + pset_data = ifcopenshell.util.element.get_pset(element, pset_name) if not pset_data: return pset_data["data_dict"] = json.loads(pset_data.get("Data", "[]") or "[]") @@ -1355,8 +1607,7 @@ class Model(bonsai.core.tool.Model): @classmethod def sync_object_ifc_position(cls, obj: bpy.types.Object) -> None: """make sure IFC position will be in sync with the Blender object position, if object was moved in Blender""" - if tool.Ifc.is_moved(obj): - bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) + tool.Geometry.commit_placement_if_moved(obj) @classmethod def get_element_matrix(cls, element: ifcopenshell.entity_instance, keep_local: bool = False) -> Matrix: @@ -1388,7 +1639,7 @@ class Model(bonsai.core.tool.Model): if not obj.data: continue element = tool.Ifc.get_entity(obj) - body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + body = tool.Geometry.get_body_representation(element) bonsai.core.geometry.switch_representation( tool.Ifc, tool.Geometry, @@ -1505,6 +1756,10 @@ class Model(bonsai.core.tool.Model): "TRIPLE_PANEL_VERTICAL", ] + RoofGenerationMethod = Literal["HEIGHT", "ANGLE"] + + RailingType = Literal["FRAMELESS_PANEL", "WALL_MOUNTED_HANDRAIL"] + @classmethod def generate_stair_2d_profile( cls, @@ -1756,7 +2011,7 @@ class Model(bonsai.core.tool.Model): from bonsai.bim.module.model.opening import FilledOpeningGenerator ifc_file = tool.Ifc.get() - fillings = {e: tool.Ifc.get_object(e) for e in tool.Ifc.get_all_element_occurrences(element)} + fillings = {e: tool.Ifc.get_object(e) for e in tool.Array.get_parametric_propagation_targets(element)} voided_objs = set() has_replaced_opening_representation = False @@ -1898,7 +2153,9 @@ class Model(bonsai.core.tool.Model): bm = bmesh.new() bm.from_mesh(mesh) - bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-4) + # Looser than auto_detect_curves' VTX_PRECISION: profiles must close into + # a single loop, so nearly-coincident endpoints should snap together. + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=WELD_TOLERANCE) bmesh.ops.delete(bm, geom=bm.faces, context="FACES_ONLY") # https://docs.blender.org/api/blender_python_api_2_63_8/bmesh.html#CustomDataAccess @@ -2126,7 +2383,7 @@ class Model(bonsai.core.tool.Model): bm = bmesh.new() bm.from_mesh(mesh) - bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5) + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=VTX_PRECISION) bmesh.ops.delete(bm, geom=bm.faces, context="FACES_ONLY") # https://docs.blender.org/api/blender_python_api_2_63_8/bmesh.html#CustomDataAccess @@ -2345,6 +2602,12 @@ class Model(bonsai.core.tool.Model): @classmethod def get_existing_x_angle(cls, extrusion: ifcopenshell.entity_instance) -> float: + """Signed slope of the extrusion's direction in the y-z plane (radians). + + Assumes extrusion directions lie in the y-z plane (LAYER2 wall and + LAYER3 slab convention). For inverted extrusions (z ≤ 0), adds π to + preserve angular continuity for callers consuming the angle via + cos/sin.""" x, y, z = extrusion.ExtrudedDirection.DirectionRatios vector = Vector((0, 1)) x_angle = vector.angle_signed(Vector((y, z))) @@ -2379,12 +2642,15 @@ class Model(bonsai.core.tool.Model): clipping_bm = bmesh.new() vertex_map = {} + kept = 0 for face in bm.faces: face.normal_update() normal = face.normal.to_4d() normal.w = 0 - if (obj.matrix_world @ normal).z >= -0.5: + world_normal_z = (obj.matrix_world @ normal).z + if world_normal_z >= -0.5: continue + kept += 1 new_verts = [] for vert in face.verts: if not (new_vert := vertex_map.get(vert.index, None)): @@ -2397,6 +2663,7 @@ class Model(bonsai.core.tool.Model): return bmesh.ops.recalc_face_normals(clipping_bm, faces=clipping_bm.faces) + clipping_bm.faces.ensure_lookup_table() return clipping_bm # clipping_bm is in project units @classmethod @@ -2410,17 +2677,53 @@ class Model(bonsai.core.tool.Model): min_z = min(zs) max_z = max(zs) - operand = None - if (z := max_z - min_z) and not np.isclose(z, 0.0): - builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) + ifc_file = tool.Ifc.get() + builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file) - result = bmesh.ops.extrude_face_region(bm, geom=bm.faces) - extruded_verts = [elem for elem in result["geom"] if isinstance(elem, bmesh.types.BMVert)] - bmesh.ops.translate(bm, verts=extruded_verts, vec=(0, 0, z)) + # Build one IfcPolygonalFaceSet clip solid per clipping face. + # Each solid uses a rectangle on the slope plane rather than the exact face + # footprint. The original approach (exact footprint) caused a kissing-solid / + # boundary-coincidence bug when the operator is called twice for a ridge roof: the + # two slope solids share an exact ridge edge, and OCCT produces spurious extra + # vertices. Extending each solid slightly past the ridge (by margin) creates a + # volumetric overlap instead of a kissing boundary — OCCT handles overlapping + # DIFFERENCE operands correctly. + margin = 1.0 # project units past the face edge — enough to ensure overlap at ridge + operands = [] + for face in bm.faces: + face.normal_update() + normal = Vector(face.normal).normalized() - verts = [v.co for v in bm.verts] - faces = [[v.index for v in p.verts] for p in bm.faces] - operand = builder.mesh(verts, faces) + # Orthonormal basis spanning the slope plane. + ref = Vector((0, 0, 1)) if abs(normal.z) < 0.9 else Vector((1, 0, 0)) + tangent1 = normal.cross(ref).normalized() + tangent2 = normal.cross(tangent1).normalized() + + centroid = sum((v.co for v in face.verts), Vector()) / len(face.verts) + + # Tight bounding rectangle in slope-plane coords, plus a small margin. + t1_coords = [(v.co - centroid).dot(tangent1) for v in face.verts] + t2_coords = [(v.co - centroid).dot(tangent2) for v in face.verts] + half1 = max(abs(c) for c in t1_coords) + margin + half2 = max(abs(c) for c in t2_coords) + margin + + # Rectangle on the slope plane, extruded upward in wall-local Z. + clip_bm = bmesh.new() + v0 = clip_bm.verts.new(centroid + half1 * tangent1 + half2 * tangent2) + v1 = clip_bm.verts.new(centroid - half1 * tangent1 + half2 * tangent2) + v2 = clip_bm.verts.new(centroid - half1 * tangent1 - half2 * tangent2) + v3 = clip_bm.verts.new(centroid + half1 * tangent1 - half2 * tangent2) + bottom_face = clip_bm.faces.new([v0, v1, v2, v3]) + result = bmesh.ops.extrude_face_region(clip_bm, geom=[bottom_face]) + top_verts = [e for e in result["geom"] if isinstance(e, bmesh.types.BMVert)] + bmesh.ops.translate(clip_bm, verts=top_verts, vec=Vector((0, 0, max_z - min_z))) + clip_bm.verts.ensure_lookup_table() + + clip_verts = [v.co for v in clip_bm.verts] + clip_faces = [[v.index for v in f.verts] for f in clip_bm.faces] + operand = builder.mesh(clip_verts, clip_faces) + clip_bm.free() + operands.append(operand) for extrusion in ifcopenshell.util.shape.get_base_extrusions(wall) or []: if extrusion.Position: @@ -2437,10 +2740,9 @@ class Model(bonsai.core.tool.Model): extrusion.Depth = max_z / direction[2] - if operand: - booleans = ifcopenshell.api.geometry.add_boolean( - tool.Ifc.get(), first_item=extrusion, second_items=[operand] - ) + if operands: + body_repr = ifcopenshell.util.representation.get_representation(wall, "Model", "Body", "MODEL_VIEW") + booleans = ifcopenshell.api.geometry.add_boolean(ifc_file, first_item=extrusion, second_items=operands) tool.Model.mark_manual_booleans(wall, booleans) @classmethod @@ -2672,7 +2974,7 @@ class Model(bonsai.core.tool.Model): def offset_wall(cls, wall: bpy.types.Object, baseline: Literal["EXTERIOR", "INTERIOR", "CENTER"]) -> None: element = tool.Ifc.get_entity(wall) usage = ifcopenshell.util.element.get_material(element) - if not usage.is_a("IfcMaterialLayerSetUsage"): + if usage is None or not usage.is_a("IfcMaterialLayerSetUsage"): return layer_set = usage.ForLayerSet if baseline == "CENTER": @@ -2693,6 +2995,20 @@ class Model(bonsai.core.tool.Model): @classmethod def recreate_wall(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None: + # Curved fillet-corner walls own a hand-built banana body that + # ``regenerate_wall_representation`` would flatten — it reads the axis + # as a 2-point reference line and builds a straight extrusion. Rebuild + # the curve in place instead: ``regenerate_fillet_corner_wall`` keeps + # radius + placement from the pset / current ``ObjectPlacement`` while + # picking up new thickness / height from the wall type, which is what + # we want when a type-property edit triggered this call. + if tool.Parametric.is_fillet_corner_wall(element): + # Lazy import: ``tool.Model`` loads before ``bim/module/model`` at + # addon enable; a module-level import would cycle. + from bonsai.bim.module.model.wall import regenerate_fillet_corner_wall + + regenerate_fillet_corner_wall(element, obj) + return rep = ifcopenshell.api.geometry.regenerate_wall_representation(tool.Ifc.get(), element) bonsai.core.geometry.switch_representation( tool.Ifc, @@ -2713,28 +3029,29 @@ class Model(bonsai.core.tool.Model): queue: set[tuple[ifcopenshell.entity_instance, bpy.types.Object]] = set() for wall in walls: element = tool.Ifc.get_entity(wall) - if tool.Ifc.is_moved(wall): - bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall) + tool.Geometry.commit_placement_if_moved(wall) queue.add((element, wall)) for rel in getattr(element, "ConnectedTo", []): obj = tool.Ifc.get_object(rel.RelatedElement) - if tool.Ifc.is_moved(obj): - bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) + tool.Geometry.commit_placement_if_moved(obj) queue.add((rel.RelatedElement, obj)) for rel in getattr(element, "ConnectedFrom", []): obj = tool.Ifc.get_object(rel.RelatingElement) - if tool.Ifc.is_moved(obj): - bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) + tool.Geometry.commit_placement_if_moved(obj) queue.add((rel.RelatingElement, obj)) for element, wall in queue: - if tool.Model.get_usage_type(element) == "LAYER2" and wall: - # Use layer custom offset + if not wall: + continue + is_layer2_usage = tool.Model.get_usage_type(element) == "LAYER2" + is_fillet_corner = tool.Parametric.is_fillet_corner_wall(element) + if not (is_layer2_usage or is_fillet_corner): + continue + if is_layer2_usage: custom_offset = tool.Model.get_material_layer_custom_offset(element, wall) material = ifcopenshell.util.element.get_material(element) if material.is_a("IfcMaterialLayerSetUsage") and custom_offset is not None: material.OffsetFromReferenceLine = custom_offset - - cls.recreate_wall(element, wall) + cls.recreate_wall(element, wall) @classmethod def regenerate_slab(cls, obj: bpy.types.Object) -> None: diff --git a/src/bonsai/bonsai/tool/parametric.py b/src/bonsai/bonsai/tool/parametric.py new file mode 100644 index 0000000000..3c3fba66f5 --- /dev/null +++ b/src/bonsai/bonsai/tool/parametric.py @@ -0,0 +1,621 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Registry and save-time auto-commit for parametric draft edits. + +The registry is consumed along two orthogonal axes: + +- **Predicate axis**: every entry carries an ``is_`` total predicate. Used + by ``find_for_element``, save-flow auto-commit, and per-feature gizmo polls. +- **Lifecycle axis**: a subset of entries flagged ``supports_build_edit_lifecycle=True`` + share the ``Enable/Finish/CancelEditing`` operator shape and are wired + through ``build_edit_lifecycle``. The remainder declare their edit operators + directly because their lifecycle (per-attribute diff dispatch, layer-stack + editing, mid-spline gizmo drag, …) does not fit the shared mixin contract. + +Adding a new parametric element type is a single entry in ``EDIT_TYPES``; +flag ``supports_build_edit_lifecycle`` only if the type's edit lifecycle matches +one of the shared mixins in ``bim/parametric_lifecycle.py``.""" + +from __future__ import annotations + +import logging +import re +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, ClassVar, Optional + +import bpy + +import bonsai.core.tool +import bonsai.tool as tool + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from ifcopenshell import entity_instance + + +# Lowercase ASCII snake_case token; each segment a non-empty letter/digit +# sequence starting with a letter. ``"pipe_segment"`` → ``"BIMPipeSegmentProperties"``. +_VALID_NAME_RE = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$") + + +def _camel_case(name: str) -> str: + return "".join(part.capitalize() for part in name.split("_")) + + +@dataclass(frozen=True) +class ParametricObject: + """One parametric element type's draft + enable + finish + cancel edit lifecycle. + + The ``name`` token drives every derived identifier: the + ``BIMProperties`` attribute on ``bpy.types.Object``, the + ``bim.enable_editing_`` / ``bim.finish_editing_`` / + ``bim.cancel_editing_`` operator ``bl_idname``s, and the + ``tool.Parametric.is_`` runtime predicate. + + The predicate is part of the contract and MUST be total — accept any IFC + entity, return a bool, never raise. A raising predicate breaks the save + path for every parametric type, not just its own. + + ``supports_build_edit_lifecycle`` marks entries whose edit lifecycle fits the + shared mixin contract (``_enable_targets`` / ``_finish_targets`` / + ``_cancel_targets``) and that therefore wire their operators through + ``build_edit_lifecycle``. Entries with bespoke edit lifecycles (per-attribute + diff dispatch, layer-stack editing, mid-spline gizmo drag) leave this + False and declare their operator classes directly.""" + + name: str + has_non_editable_path: bool = False + supports_build_edit_lifecycle: bool = False + + def __post_init__(self) -> None: + if not _VALID_NAME_RE.match(self.name): + raise ValueError( + f"ParametricObject name {self.name!r} must match " + f"{_VALID_NAME_RE.pattern!r} — lowercase letters / digits, " + f"optionally split by single underscores (e.g. ``door`` or " + f"``pipe_segment``). Leading / trailing underscores and " + f"consecutive underscores are rejected because they produce " + f"empty CamelCase segments in derived class names." + ) + + @property + def props_attr(self) -> str: + return f"BIM{_camel_case(self.name)}Properties" + + @property + def enable_op(self) -> str: + return f"bim.enable_editing_{self.name}" + + @property + def finish_op(self) -> str: + return f"bim.finish_editing_{self.name}" + + @property + def cancel_op(self) -> str: + return f"bim.cancel_editing_{self.name}" + + def is_editing(self, obj: bpy.types.Object) -> bool: + props = getattr(obj, self.props_attr, None) + return bool(props and getattr(props, "is_editing", False)) + + +class Parametric(bonsai.core.tool.Parametric): + class GenerationKeyedCache: + """A dict-keyed cache stamped with the parametric generation counter + at fill time. Reads at a later generation drop the whole dict and + re-run the loader. Any IFC commit bumps the generation, invalidating + all entries en bloc. + + ``None`` values are stored verbatim; only "key not in dict" counts as + a miss.""" + + def __init__(self) -> None: + self._gen: int | None = None + self._data: dict = {} + + def get_or_compute(self, key, loader): + current = Parametric.get_geom_generation() + if self._gen != current: + self._data.clear() + self._gen = current + if key not in self._data: + self._data[key] = loader() + return self._data[key] + + def clear(self) -> None: + """Explicit drop. Use from ``load_post`` so a fresh file starts clean.""" + self._data.clear() + self._gen = None + + EDIT_TYPES: list[ParametricObject] = [ + ParametricObject("door", has_non_editable_path=True, supports_build_edit_lifecycle=True), + ParametricObject("window", has_non_editable_path=True, supports_build_edit_lifecycle=True), + ParametricObject("stair", has_non_editable_path=True, supports_build_edit_lifecycle=True), + ParametricObject("railing", supports_build_edit_lifecycle=True), + ParametricObject("roof", supports_build_edit_lifecycle=True), + ParametricObject("array", supports_build_edit_lifecycle=True), + ParametricObject("pipe_segment", supports_build_edit_lifecycle=True), + ParametricObject("duct_segment", supports_build_edit_lifecycle=True), + ParametricObject("wall"), + ] + + # Annotations for the uppercase constants populated from ``EDIT_TYPES`` by + # the binding loop at module bottom. Declared here so IDEs and type + # checkers see the attributes without running the loop. + DOOR: ClassVar[ParametricObject] + WINDOW: ClassVar[ParametricObject] + STAIR: ClassVar[ParametricObject] + RAILING: ClassVar[ParametricObject] + ROOF: ClassVar[ParametricObject] + ARRAY: ClassVar[ParametricObject] + PIPE_SEGMENT: ClassVar[ParametricObject] + DUCT_SEGMENT: ClassVar[ParametricObject] + WALL: ClassVar[ParametricObject] + + _geom_generation: int = 0 + + @classmethod + def get_geom_generation(cls) -> int: + return cls._geom_generation + + @classmethod + def refresh_post_commit(cls, operator: bpy.types.Operator) -> None: + """Post-commit hook for ``tool.Ifc.Operator``: bumps the geometry + generation counter so caches keyed off it drop stale entries on + the next draw, and tags viewports for redraw. + + Additionally refreshes the BIM Tool header floats for the + validate-gizmo path — operators whose ``bl_idname`` is the + ``finish_op`` of an entry in ``EDIT_TYPES``. That is the only + commit class where selection didn't change but the header + values displayed did. Other operators skip the refresh: they + don't target an active-object header edit, and their commit + context may lack the view-layer attributes the refresh reads.""" + cls._geom_generation += 1 + tool.Blender.update_all_viewports() + if operator.bl_idname in {feature.finish_op for feature in cls.EDIT_TYPES}: + import bonsai.bim.handler # late import: bim.handler imports tool.* + + bonsai.bim.handler.refresh_bim_tool_headers() + + @classmethod + def find_by_name(cls, name: str) -> Optional[ParametricObject]: + return next((f for f in cls.EDIT_TYPES if f.name == name), None) + + @classmethod + def _safe_predicate(cls, feature: ParametricObject, element: entity_instance) -> bool: + """Resolve and invoke ``is_`` defensively. The contract is + that predicates are total (see ``ParametricObject`` docstring); a + regression that turns one predicate raising would otherwise break the + save path for every parametric type, not just its own.""" + predicate = getattr(cls, f"is_{feature.name}", None) + if predicate is None: + return False + try: + return bool(predicate(element)) + except Exception: + logger.warning( + "parametric predicate is_%s raised on %r", + feature.name, + element, + exc_info=True, + ) + return False + + @classmethod + def find_for_element(cls, element: entity_instance) -> Optional[ParametricObject]: + """Return the registry entry whose IFC type predicate matches ``element``.""" + for feature in cls.EDIT_TYPES: + if cls._safe_predicate(feature, element): + return feature + return None + + @classmethod + def is_object_editing(cls, obj: bpy.types.Object, skip_name: Optional[str] = None) -> Optional[ParametricObject]: + """Return the registry entry whose edit lifecycle is active on ``obj``, or None. + + ``skip_name`` excludes one entry from the scan, for callers that want + to know if a *different* type is editing.""" + for feature in cls.EDIT_TYPES: + if feature.name == skip_name: + continue + if feature.is_editing(obj): + return feature + return None + + @classmethod + def _validated_editing_feature(cls, obj: bpy.types.Object) -> Optional[ParametricObject]: + """Return the active registry entry on ``obj``, validated against the + per-type predicate. Returns None when no ``is_editing`` flag is set + or when the flag is stale. + + Self-heals: a predicate mismatch clears the flag in place so the + finish dispatch never re-picks up a phantom edit.""" + feature = cls.is_object_editing(obj) + if feature is None: + return None + element = tool.Ifc.get_entity(obj) + if element is None or not cls._safe_predicate(feature, element): + getattr(obj, feature.props_attr).is_editing = False + return None + return feature + + @classmethod + def heal_stale_edit_flags(cls) -> None: + """Validate every scene object's ``is_editing`` flag against the + per-type predicate, clearing stale flags in place. + + Run from ``load_post`` so a ``.blend`` saved with phantom flags + (e.g. a save that bypassed the auto-commit flush) is consistent the + moment it opens.""" + for obj in bpy.data.objects: + cls._validated_editing_feature(obj) + + @classmethod + def on_load_post(cls, scene: bpy.types.Scene) -> None: + """Drain load-transient parametric state on a freshly opened scene + so no draft edit flag, preview flag, or cache entry persists from + the saved file.""" + from bonsai.bim.module.model import wall_offset_gizmos + from bonsai.bim.module.model.preview_base import discard_pending_previews + + cls.heal_stale_edit_flags() + discard_pending_previews(scene) + wall_offset_gizmos.clear_caches() + + @classmethod + def get_pending_edits(cls) -> list[tuple[bpy.types.Object, str]]: + """``(object, finish_operator_bl_idname)`` pairs for every object + with an in-progress parametric draft. Stale flags are cleared in + place and excluded.""" + pending: list[tuple[bpy.types.Object, str]] = [] + for obj in bpy.data.objects: + feature = cls._validated_editing_feature(obj) + if feature is not None: + pending.append((obj, feature.finish_op)) + return pending + + @classmethod + def run_bim_op(cls, bl_idname: str) -> None: + """Invoke a ``bim.*`` operator by ``bl_idname``. + + Asserts the operator is a ``tool.Ifc.Operator`` subclass — bypassing + that wrap would mutate IFC outside Bonsai's transaction system.""" + verb = bl_idname.removeprefix("bim.") + op_cls = getattr(bpy.types, f"BIM_OT_{verb}", None) + if op_cls is None or not issubclass(op_cls, tool.Ifc.Operator): + raise RuntimeError( + f"{bl_idname!r} must be a registered tool.Ifc.Operator subclass for undo-safe IFC mutation" + ) + getattr(bpy.ops.bim, verb)() + + @classmethod + def commit_object_draft(cls, obj: bpy.types.Object, finish_op: str) -> bool: + """Run ``finish_op`` scoped to ``obj`` alone. Returns False (with + traceback printed) if the operator raised. + + Both ``temp_override`` and ``view_layer.objects.active`` are set: + ``temp_override`` does not rebind ``objects.active``, and some finish + operators read it directly.""" + view_layer = bpy.context.view_layer + original_active = view_layer.objects.active + try: + with bpy.context.temp_override(active_object=obj, selected_objects=[obj]): + view_layer.objects.active = obj + try: + cls.run_bim_op(finish_op) + return True + except Exception: + logger.warning( + "commit of %r via %s failed", + obj.name, + finish_op, + exc_info=True, + ) + return False + finally: + view_layer.objects.active = original_active + + @classmethod + def commit_pending_edits(cls) -> tuple[int, list[bpy.types.Object]]: + """Run each pending draft's finish operator scoped to its object. + + A per-object failure does not abort the loop — remaining drafts + still flush, otherwise the auto-commit would ship the exact silent + desync it exists to prevent.""" + committed = 0 + failed: list[bpy.types.Object] = [] + for obj, finish_op in cls.get_pending_edits(): + if cls.commit_object_draft(obj, finish_op): + committed += 1 + else: + failed.append(obj) + return committed, failed + + @classmethod + def commit_pending_edits_for_selection( + cls, names: Optional[tuple[str, ...]] = None + ) -> tuple[int, list[bpy.types.Object]]: + """Selection-scoped variant. ``names`` filters which registry entries + to consider; ``None`` considers every type.""" + committed = 0 + failed: list[bpy.types.Object] = [] + for obj in tool.Blender.get_selected_objects(): + feature = cls._validated_editing_feature(obj) + if feature is None: + continue + if names is not None and feature.name not in names: + continue + if cls.commit_object_draft(obj, feature.finish_op): + committed += 1 + else: + failed.append(obj) + return committed, failed + + @classmethod + def _assert_predicates_registered(cls) -> None: + """Loud at addon-enable if any ``EDIT_TYPES`` entry has no matching + ``is_`` classmethod. Without this, a typo in the registry entry + produces a silent-False predicate that never matches — every + parametric draft of that type bypasses save-flow auto-commit.""" + missing = [feature.name for feature in cls.EDIT_TYPES if not callable(getattr(cls, f"is_{feature.name}", None))] + if missing: + raise RuntimeError( + f"tool.Parametric.EDIT_TYPES has entries with no is_ predicate: {missing}. " + f"Add `is_(cls, element) -> bool` classmethods on tool.Parametric, " + f"or remove the entries from EDIT_TYPES." + ) + + @classmethod + def register_object_properties(cls, prop_module) -> None: + """Attach ``bpy.types.Object.BIMProperties`` for every registered + parametric type. Skips entries whose ``PropertyGroup`` is absent.""" + cls._assert_predicates_registered() + for feature in cls.EDIT_TYPES: + prop_cls = getattr(prop_module, feature.props_attr, None) + if prop_cls is None: + continue + setattr(bpy.types.Object, feature.props_attr, bpy.props.PointerProperty(type=prop_cls)) + + @classmethod + def unregister_object_properties(cls) -> None: + for feature in cls.EDIT_TYPES: + if hasattr(bpy.types.Object, feature.props_attr): + delattr(bpy.types.Object, feature.props_attr) + + # --- Feature-kind predicates ------------------------------------------------ + # One predicate per registered parametric type. Each is total: accepts any + # IFC entity (or None), returns a bool, never raises. Predicates live with + # the registry rather than ``tool.Blender.Modifier`` because they ARE the + # registry contract — ``find_for_element`` and ``_validated_editing_feature`` + # resolve them by name. Coupling them on the same class makes a typo at + # registration time an immediate AttributeError instead of a silent None + # predicate that never matches. + + @classmethod + def is_array(cls, element: entity_instance) -> bool: + """True if element is the PARENT of a Bonsai parametric array. + + Array children also carry a ``BBIM_Array`` pset (their ``Parent`` + field points back to the original), so checking pset presence alone + would falsely match them. The parent is distinguished by + ``pset.Parent == element.GlobalId``.""" + import ifcopenshell.util.element + + if element is None: + return False + pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + if not pset: + return False + return pset.get("Parent") == element.GlobalId + + @classmethod + def is_railing(cls, element: entity_instance) -> bool: + if element is None: + return False + return tool.Pset.get_element_pset(element, "BBIM_Railing") is not None + + @classmethod + def is_roof(cls, element: entity_instance) -> bool: + if element is None: + return False + return tool.Pset.get_element_pset(element, "BBIM_Roof") is not None + + @classmethod + def is_window(cls, element: entity_instance) -> bool: + if element is None: + return False + return tool.Pset.get_element_pset(element, "BBIM_Window") is not None + + @classmethod + def is_door(cls, element: entity_instance) -> bool: + if element is None: + return False + return tool.Pset.get_element_pset(element, "BBIM_Door") is not None + + @classmethod + def is_stair(cls, element: entity_instance) -> bool: + if element is None: + return False + return tool.Pset.get_element_pset(element, "BBIM_Stair") is not None + + @classmethod + def is_wall(cls, element: entity_instance) -> bool: + """A wall is editable by the parametric gizmo if it is an IfcWall with LAYER2 usage. + + Unlike doors/windows/stairs, walls do not carry a proprietary BBIM_Wall pset — + their parametric state lives in standard IFC (axis polyline, IfcMaterialLayerSetUsage, + IfcExtrudedAreaSolid). Any LAYER2 wall qualifies.""" + if element is None or not element.is_a("IfcWall"): + return False + return tool.Model.get_usage_type(element) == "LAYER2" + + @classmethod + def is_path_connectable_wall(cls, element: entity_instance) -> bool: + """An IfcWall that may participate in IfcRelConnectsPathElements joins — + either a LAYER2 parametric wall, or a fillet-corner wall whose body is + hand-built but whose axis still drives path connections. + + Distinct from ``is_wall``: that predicate gates parametric edits that + would regenerate the body and flatten a curved fillet. Unjoin / join + gizmo polls and path-connection partner enumeration use this looser + predicate so fillet corners (which have no LAYER2 usage by spec) still + surface their join icons.""" + if element is None or not element.is_a("IfcWall"): + return False + if tool.Model.get_usage_type(element) == "LAYER2": + return True + return cls.is_fillet_corner_wall(element) + + @classmethod + def is_fillet_corner_wall(cls, element: entity_instance) -> bool: + """``True`` if the wall carries the ``BBIM_Wall.IsFilletCorner`` flag, + marking it as a curved corner whose banana body is hand-built rather + than regenerated from the wall's axis + layer set.""" + import ifcopenshell.util.element + + return bool(ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "IsFilletCorner")) + + @classmethod + def is_pipe_segment(cls, element: entity_instance) -> bool: + return element is not None and element.is_a("IfcPipeSegment") + + @classmethod + def is_duct_segment(cls, element: entity_instance) -> bool: + return element is not None and element.is_a("IfcDuctSegment") + + @classmethod + def build_edit_lifecycle( + cls, + feature_name: str, + mixin: type, + labels: tuple[tuple[str, str], tuple[str, str], tuple[str, str]], + bl_options: Optional[set[str]] = None, + enable_extra_props: Optional[dict[str, Any]] = None, + enable_extra_kwargs: Optional[Callable[[Any], dict[str, Any]]] = None, + module_name: Optional[str] = None, + ) -> tuple[type, type, type]: + """Generate (Enable, Finish, Cancel) operator classes for a parametric type. + + ``mixin`` provides ``_enable_targets`` / ``_finish_targets`` / + ``_cancel_targets`` (i.e. inherits from ``ParametricEditMixinBase`` or + a sibling). ``labels`` is ``((enable_label, enable_desc), …)`` in + Enable / Finish / Cancel order. + + ``bl_idname`` and the Python class name come from the registry entry — + ``feature_name`` MUST already be in ``EDIT_TYPES``, otherwise a typo + produces an unregistered operator. Anchoring bl_idnames to the registry + eliminates the silent-mismatch failure mode where a hand-typed + ``bl_idname = "bim.enable_editing_dor"`` produces a class that + ``find_for_element`` never resolves to. + + ``enable_extra_props`` declares extra ``bpy.props.*`` descriptors to + attach to the Enable class only (e.g. array's ``item: IntProperty`` + carrying the target layer index across redo). When set, + ``enable_extra_kwargs`` must also be supplied: it receives the Enable + operator instance and returns a kwargs dict forwarded to + ``_enable_targets`` so the mixin's enable phase sees the extras. + + ``module_name`` sets ``__module__`` on the generated classes — pass + ``__name__`` from the calling feature module so Blender's right-click + → Edit Source resolves to the feature module rather than the factory + site. Defaults to the factory's module, which is sub-optimal for + debugging but harmless.""" + import bonsai.tool as _tool # late import: tool/__init__.py wires this module last + + feature = cls.find_by_name(feature_name) + if feature is None: + raise RuntimeError( + f"build_edit_lifecycle: {feature_name!r} not in EDIT_TYPES — add a " + f"ParametricObject entry before declaring its operators" + ) + if not feature.supports_build_edit_lifecycle: + raise RuntimeError( + f"build_edit_lifecycle: {feature_name!r} has supports_build_edit_lifecycle=False — " + f"its edit lifecycle is bespoke. Either declare " + f"Enable/Finish/CancelEditing{_camel_case(feature_name)} as direct Operator " + f"subclasses, or flip the flag on the EDIT_TYPES entry if the type does fit " + f"the shared mixin contract." + ) + if (enable_extra_props is None) != (enable_extra_kwargs is None): + raise RuntimeError( + f"build_edit_lifecycle({feature_name!r}): enable_extra_props and " + f"enable_extra_kwargs must be supplied together — extras with no " + f"kwargs builder are unreachable, kwargs with no extras have nothing to forward" + ) + options = bl_options if bl_options is not None else {"REGISTER", "UNDO"} + base_classes = (mixin, bpy.types.Operator, _tool.Ifc.Operator) + capitalised = _camel_case(feature_name) + + def _build( + action: str, bl_idname: str, label: str, desc: str, target_method: str, extras: Optional[dict] + ) -> type: + if extras and target_method == "_enable_targets": + assert enable_extra_kwargs is not None + kwargs_builder = enable_extra_kwargs + + def _execute(self, context: bpy.types.Context) -> set[str]: + return getattr(self, target_method)(context, **kwargs_builder(self)) + + else: + + def _execute(self, context: bpy.types.Context) -> set[str]: + return getattr(self, target_method)(context) + + attrs: dict[str, Any] = { + "bl_idname": bl_idname, + "bl_label": label, + "bl_description": desc, + "bl_options": options, + "_execute": _execute, + } + if module_name is not None: + attrs["__module__"] = module_name + if extras: + # Blender's PropertyGroup machinery reads __annotations__ for bpy.props descriptors. + attrs["__annotations__"] = dict(extras) + return type(f"{action}Editing{capitalised}", base_classes, attrs) + + return ( + _build("Enable", feature.enable_op, labels[0][0], labels[0][1], "_enable_targets", enable_extra_props), + _build("Finish", feature.finish_op, labels[1][0], labels[1][1], "_finish_targets", None), + _build("Cancel", feature.cancel_op, labels[2][0], labels[2][1], "_cancel_targets", None), + ) + + +_edit_type_names = [entry.name for entry in Parametric.EDIT_TYPES] +if len(set(_edit_type_names)) != len(_edit_type_names): + raise RuntimeError( + f"EDIT_TYPES name collision: {_edit_type_names}. Each name is the primary key " + f"for derived bl_idnames, BIMProperties attributes, is_ predicates, " + f"and the uppercase constant — a duplicate silently shadows the first entry." + ) +del _edit_type_names + +# Bind every registered ParametricObject as an uppercase class attribute so +# call sites can reference ``tool.Parametric.ROOF`` directly. Renaming a +# registry entry renames the constant; a typo at the call site surfaces as +# AttributeError at module load. +for _entry in Parametric.EDIT_TYPES: + setattr(Parametric, _entry.name.upper(), _entry) +del _entry diff --git a/src/bonsai/bonsai/tool/pset.py b/src/bonsai/bonsai/tool/pset.py index 2e2fc4383c..aa72a85e6f 100644 --- a/src/bonsai/bonsai/tool/pset.py +++ b/src/bonsai/bonsai/tool/pset.py @@ -18,10 +18,12 @@ from __future__ import annotations +import json from typing import TYPE_CHECKING, Any, Literal, Union, assert_never import bpy import ifcopenshell +import ifcopenshell.api.pset import ifcopenshell.util.attribute import ifcopenshell.util.element @@ -74,6 +76,34 @@ class Pset(bonsai.core.tool.Pset): if pset: return tool.Ifc.get().by_id(pset["id"]) + @classmethod + def upsert_pset( + cls, + element: ifcopenshell.entity_instance, + pset_name: str, + properties: dict[str, Any], + ) -> ifcopenshell.entity_instance: + """Get or create ``pset_name`` on ``element``, write ``properties``, return the pset. + Centralises the get-element-pset → add-pset-if-missing → edit-pset idiom.""" + ifc_file = tool.Ifc.get() + pset = cls.get_element_pset(element, pset_name) + if not pset: + pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name=pset_name) + ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties=properties) + return pset + + @classmethod + def write_bbim_data( + cls, + element: ifcopenshell.entity_instance, + pset_name: str, + data: dict[str, Any], + ) -> ifcopenshell.entity_instance: + """Get or create the BBIM_ pset and write ``data`` as the IfcText-serialised + JSON ``Data`` property. Canonical writer for parametric-modifier pset state.""" + data_text = tool.Ifc.get().createIfcText(json.dumps(data, default=list)) + return cls.upsert_pset(element, pset_name, {"Data": data_text}) + @classmethod def get_pset_props(cls, obj: str, obj_type: tool.Ifc.OBJECT_TYPE) -> PsetProperties: if obj_type == "Object": diff --git a/src/bonsai/bonsai/tool/raycast.py b/src/bonsai/bonsai/tool/raycast.py index dc45b4e9bb..2ce5d4bca4 100644 --- a/src/bonsai/bonsai/tool/raycast.py +++ b/src/bonsai/bonsai/tool/raycast.py @@ -373,26 +373,42 @@ class Raycast(bonsai.core.tool.Raycast): except: loc = Vector((0, 0, 0)) - verts_2d = [ - view3d_utils.location_3d_to_region_2d(region, rv3d, v) for v in snap_obj.verts_3d - ] # Numpy version is worst in performance + snap_obj._ensure_bvh() intersected = snap_obj.raycast_boxes( context, event, snap_obj.root, intersected=[], rays=(ray_origin, ray_direction) ) + + # Collect edges from intersected BVH boxes edges = [] for it in intersected: edges.extend(it.edges) edges = set(edges) + # Build only the vertices indices that belong to these edges + verts_idx: set[int] = set() + for e in edges: + ev = snap_obj.obj.data.edges[e].vertices + verts_idx.add(ev[0]) + verts_idx.add(ev[1]) + + # Lazily project only the needed vertices to 2D screen space + verts_2d: dict[int, Vector] = {} + for idx in verts_idx: + v2d = view3d_utils.location_3d_to_region_2d( + region, rv3d, snap_obj.verts_3d[idx] + ) + if v2d is not None: + verts_2d[idx] = v2d + + edge_verts = {} for e in edges: - verts_idx = tuple(snap_obj.obj.data.edges[e].vertices) - verts = snap_obj.obj.data.vertices - v1 = snap_obj.obj.matrix_world @ verts[verts_idx[0]].co - v1_2d = verts_2d[verts_idx[0]] - v2 = snap_obj.obj.matrix_world @ verts[verts_idx[1]].co - v2_2d = verts_2d[verts_idx[1]] + verts_idx = snap_obj.obj.data.edges[e].vertices + v1 = snap_obj.verts_3d[verts_idx[0]] + v2 = snap_obj.verts_3d[verts_idx[1]] + v1_2d = verts_2d.get(verts_idx[0]) + v2_2d = verts_2d.get(verts_idx[1]) if (v1_2d is None) ^ (v2_2d is None): point, _ = cls.intersect_edge_region_border(region, context.space_data, rv3d, v1, v2) if v1_2d is None: @@ -404,10 +420,16 @@ class Raycast(bonsai.core.tool.Raycast): snap_threshold = 10.0 - for i, point in enumerate(verts_2d): - if not point: - continue - distance = (Vector(mouse_pos) - point).length + # Check all vertices for proximity to mouse position. + # Re-use the 2D projections already computed for edge endpoints. + for i, v3d in enumerate(snap_obj.verts_3d): + if i in verts_2d: + v2d = verts_2d[i] + else: + v2d = view3d_utils.location_3d_to_region_2d(region, rv3d, v3d) + if v2d is None: + continue + distance = (Vector(mouse_pos) - v2d).length if distance <= snap_threshold: snap_point = { "object": snap_obj.obj, @@ -799,6 +821,30 @@ class Raycast(bonsai.core.tool.Raycast): else: return None, None, None + @classmethod + def process_wireframe_snap_obj( + cls, + context: bpy.types.Context, + event: bpy.types.Event, + snap_obj, + ray_origin: Vector, + closest_snaps: list, + ): + snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj) + hit_obj = None + hit = None + if snap_points: + closest_length_squared = float("inf") + for point in snap_points: + point["group"] = "Wireframe" + closest_snaps.append(point) + length = (point["point"] - ray_origin).length_squared + if length < closest_length_squared: + closest_length_squared = length + hit = point["point"] + hit_obj = point["object"] + return hit_obj, hit + @classmethod def ray_cast_and_get_closest_to_camera_snaps( cls, @@ -813,35 +859,45 @@ class Raycast(bonsai.core.tool.Raycast): ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event) + space = context.space_data + xray_mode = (space.shading.type == "SOLID" and space.shading.show_xray) or ( + space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe + ) + closest_snaps = [] - hit = None - for snap_obj in objs_to_raycast: - if snap_obj.obj.type in {"EMPTY", "CURVE"} or ( - hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0 - ): - # For wireframe objects we have to test all the snaps to see which is closer - snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj) - closest_wf_hit = None - closest_wf_length_squared = 1.0 - closest_wf_point = None - if snap_points: - for point in snap_points: - point["group"] = "Wireframe" - closest_snaps.append(point) - length = (point["point"] - ray_origin).length_squared - if closest_wf_hit is None or length < closest_wf_length_squared: - closest_wf_length_squared = length - closest_wf_hit = point["point"] - closest_wf_point = point + if not xray_mode and objs_to_raycast: + # Non-xray - only the closest solid object's Face snap is kept by + # the caller (detect_snapping_points). Process solids in distance + # order and stop at the first hit to minimise raycasts. + wireframe_objs = [] + solid_objs = [] + for snap_obj in objs_to_raycast: + if snap_obj.obj.type in {"EMPTY", "CURVE"} or ( + hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0 + ): + wireframe_objs.append(snap_obj) + else: + solid_objs.append(snap_obj) - if closest_wf_point: - hit_obj = closest_wf_point["object"] - hit = closest_wf_point["point"] - face_index = None + # Rough distance - object origin to ray origin + solid_objs.sort(key=lambda so: (so.obj.matrix_world.translation - ray_origin).length_squared) - else: - # Solid objects + # Process wireframe objects first (all of them, always collected) + for snap_obj in wireframe_objs: + hit_obj, hit = cls.process_wireframe_snap_obj( + context, event, snap_obj, ray_origin, closest_snaps + ) + if hit is not None: + length_squared = (hit - ray_origin).length_squared + if closest_obj is None or length_squared < closest_length_squared: + closest_length_squared = length_squared + closest_obj = hit_obj + closest_hit = hit + closest_face_index = None + + # Process solid objects in distance order, stop at first hit + for snap_obj in solid_objs: hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj) if hit: @@ -855,14 +911,47 @@ class Raycast(bonsai.core.tool.Raycast): } closest_snaps.append(snap_point) - # Here we test which is closer, including wireframe and solid objects - if hit is not None: - length_squared = (hit - ray_origin).length_squared - if closest_obj is None or length_squared < closest_length_squared: - closest_length_squared = length_squared - closest_obj = hit_obj - closest_hit = hit - closest_face_index = face_index + length_squared = (hit - ray_origin).length_squared + if closest_obj is None or length_squared < closest_length_squared: + closest_length_squared = length_squared + closest_obj = hit_obj + closest_hit = hit + closest_face_index = face_index + + break + + else: + # Xray mode - process all objects (all snaps are kept by the caller) + for snap_obj in objs_to_raycast: + if snap_obj.obj.type in {"EMPTY", "CURVE"} or ( + hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0 + ): + hit_obj, hit = cls.process_wireframe_snap_obj( + context, event, snap_obj, ray_origin, closest_snaps + ) + face_index = None + else: + # Solid objects + hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj) + + if hit: + snap_point = { + "point": hit, + "type": "Face", + "group": "Object", + "object": hit_obj, + "face_index": face_index, + "distance": 9, # High value so it has low priority + } + closest_snaps.append(snap_point) + + if hit is not None: + length_squared = (hit - ray_origin).length_squared + if closest_obj is None or length_squared < closest_length_squared: + closest_length_squared = length_squared + closest_obj = hit_obj + closest_hit = hit + closest_face_index = face_index # Label snaps from the closest object if closest_obj is not None: @@ -936,12 +1025,19 @@ class SnapObj: def __init__(self, obj: bpy.types.Object): self.__class__.all.append(self) self.obj = obj - self.root = self._create_root_node() - self.root.edges = [e.index for e in obj.data.edges] - self.split_box(self.root, 0) + self.root = None + self._bvh_built = False self.verts_3d = [obj.matrix_world @ v.co for v in obj.data.vertices] self.snap_points = [] + def _ensure_bvh(self): + if self._bvh_built: + return + self.root = self._create_root_node() + self.root.edges = [e.index for e in self.obj.data.edges] + self.split_box(self.root, 0) + self._bvh_built = True + def __clear_all__(): for instance in SnapObj.all: del instance diff --git a/src/bonsai/bonsai/tool/root.py b/src/bonsai/bonsai/tool/root.py index 8880a168fe..d7b7e0596f 100644 --- a/src/bonsai/bonsai/tool/root.py +++ b/src/bonsai/bonsai/tool/root.py @@ -71,6 +71,18 @@ class Root(bonsai.core.tool.Root): should_use_presentation_style_assignment=props.should_use_presentation_style_assignment, ) + @classmethod + def has_material_styles(cls, element: ifcopenshell.entity_instance) -> bool: + """``True`` if any constituent material on ``element`` carries a style + representation. Body styles should NOT be applied directly when this + is True — the material-inherited style is the authoritative source. + Paired with ``assign_body_styles``: callers check this first and only + call ``assign_body_styles`` when it returns False.""" + materials = ifcopenshell.util.element.get_materials(element) + if not materials: + return False + return any(getattr(m, "HasRepresentation", None) for m in materials) + @classmethod def copy_representation( cls, source: ifcopenshell.entity_instance, dest: ifcopenshell.entity_instance @@ -381,7 +393,7 @@ class Root(bonsai.core.tool.Root): # Make sure that the array children also get reassigned to the correct aggregate pset = ifcopenshell.util.element.get_pset(new[0], "BBIM_Array") if pset: - array_children = tool.Blender.Modifier.Array.get_all_children_objects(new[0]) + array_children = tool.Array.get_all_children_objects(new[0]) for obj in array_children: bonsai.core.aggregate.assign_object( tool.Ifc, diff --git a/src/bonsai/bonsai/tool/slab.py b/src/bonsai/bonsai/tool/slab.py new file mode 100644 index 0000000000..5ad85b2222 --- /dev/null +++ b/src/bonsai/bonsai/tool/slab.py @@ -0,0 +1,74 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Side-effect-free slab helpers — IFC reads for LAYER3 extrusions. + +Exposes ``read_geometry``: a single live read of the parametric attributes +(extrusion depth and slope) that drive icon placement and dimension display +on a LAYER3 slab. Lives in ``tool/`` so bim-layer callers can stay +declarative — they get a dict, not an IFC walk.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, TypedDict + +import ifcopenshell.util.unit + +import bonsai.core.tool +import bonsai.tool as tool + +if TYPE_CHECKING: + import bpy + + +class SlabGeometry(TypedDict): + depth: float + x_angle: float + + +class Slab(bonsai.core.tool.Slab): + @classmethod + def read_geometry(cls, obj: bpy.types.Object) -> SlabGeometry | None: + """Live-read slab parametric geometry as a dict, or ``None`` if the + object is not a LAYER3 extruded slab. + + Returned keys (all SI units): ``depth`` (extrusion thickness along the + slab's local Z), ``x_angle`` (slope in radians; zero for level slabs). + + The slope is encoded in ``obj.matrix_world`` as a post-rotation, so + callers projecting world points into slab-local space via + ``mw.inverted()`` will see a level frame whose Z runs along the slab + thickness — ``x_angle`` is reported for callers that need the slope + as a scalar but is already applied by the placement.""" + element = tool.Ifc.get_entity(obj) + if not element or not tool.Blender.Modifier.is_slab(element): + return None + representation = tool.Geometry.get_body_representation(element) + if not representation: + return None + extrusion = tool.Model.get_extrusion(representation) + if not extrusion: + return None + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + x_angle = tool.Model.get_existing_x_angle(extrusion) + return { + "depth": extrusion.Depth * unit_scale, + "x_angle": x_angle, + } diff --git a/src/bonsai/bonsai/tool/spatial.py b/src/bonsai/bonsai/tool/spatial.py index 11a41672bc..df87d493c7 100644 --- a/src/bonsai/bonsai/tool/spatial.py +++ b/src/bonsai/bonsai/tool/spatial.py @@ -80,16 +80,39 @@ class Spatial(bonsai.core.tool.Spatial): def get_root_element(cls, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: while True: if parent := ( - ifcopenshell.util.element.get_aggregate(element) - or ifcopenshell.util.element.get_nest(element) - or ifcopenshell.util.element.get_filled_void(element) - or ifcopenshell.util.element.get_voided_element(element) + ifcopenshell.util.element.get_aggregate(element) or ifcopenshell.util.element.get_nest(element) ): element = parent else: break return element + @classmethod + def get_host_element(cls, filling: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None: + """The building element that hosts a filling (door/window) via the + standard ``FillsVoids → RelatingOpeningElement → VoidsElements → + RelatingBuildingElement`` chain, with safety guards at each hop. + Returns ``None`` if any link is missing, or if the given entity is + not a fillable type (no ``FillsVoids`` inverse). + + For the wall-only case (gizmos that only make sense on walls), use + `get_host_wall` which adds an ``IfcWall`` type filter on top of this.""" + if not getattr(filling, "FillsVoids", None): + return None + opening = filling.FillsVoids[0].RelatingOpeningElement + if not opening.VoidsElements: + return None + return opening.VoidsElements[0].RelatingBuildingElement + + @classmethod + def get_host_wall(cls, filling: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None: + """The ``IfcWall`` that hosts a filling (door/window), or ``None``. + + Walls only — fillings hosted in slabs / roofs / arbitrary elements + produce ``None`` so wall-offset callers stay opted out cleanly.""" + host = cls.get_host_element(filling) + return host if host and host.is_a("IfcWall") else None + @classmethod def can_contain(cls, container: ifcopenshell.entity_instance, element: ifcopenshell.entity_instance) -> bool: if tool.Ifc.get_schema() == "IFC2X3": diff --git a/src/bonsai/bonsai/tool/style.py b/src/bonsai/bonsai/tool/style.py index 8db3ed30fe..83f1751e96 100644 --- a/src/bonsai/bonsai/tool/style.py +++ b/src/bonsai/bonsai/tool/style.py @@ -203,6 +203,11 @@ class Style(bonsai.core.tool.Style): available_props = props.bl_rna.properties.keys() for prop_blender, prop_ifc in STYLE_PROPS_MAP.items(): + null_prop_name = f"is_{prop_blender}_null" + if null_prop_name in available_props and getattr(props, null_prop_name): + surface_style_data[prop_ifc] = None + continue + class_prop_name = f"{prop_blender}_class" # get detailed color properties if available diff --git a/src/bonsai/bonsai/tool/system.py b/src/bonsai/bonsai/tool/system.py index 926bceca91..29b5223d9b 100644 --- a/src/bonsai/bonsai/tool/system.py +++ b/src/bonsai/bonsai/tool/system.py @@ -19,6 +19,7 @@ from __future__ import annotations import re +from collections import deque from enum import Enum from typing import TYPE_CHECKING, Any, Optional, Union @@ -26,6 +27,7 @@ import bpy import ifcopenshell.api.geometry import ifcopenshell.api.system import ifcopenshell.util.element +import ifcopenshell.util.placement import ifcopenshell.util.system from mathutils import Matrix, Vector @@ -35,12 +37,29 @@ import bonsai.core.root import bonsai.core.tool import bonsai.tool as tool from bonsai.bim import import_ifc -from bonsai.bim.module.system.data import ObjectSystemData, SystemDecorationData + +# Data-class imports from ``bonsai.bim.module.system.data`` are function-local: +# a top-level import would trigger a partial-init cycle through tool.Ifc.Operator. if TYPE_CHECKING: from bonsai.bim.module.system.prop import BIMSystemProperties, BIMZoneProperties +_DIRECTION_FROM_FLOW_PAIR: dict[tuple[str, str], str] = { + ("SOURCE", "SINK"): "SOURCE", + ("SINK", "SOURCE"): "SINK", + ("SOURCEANDSINK", "SOURCEANDSINK"): "SOURCEANDSINK", +} + + +def direction_from_port_pair(port_a: ifcopenshell.entity_instance, port_b: ifcopenshell.entity_instance) -> str: + """Derive the ``direction`` arg for ``ifcopenshell.api.system.connect_port`` + from each port's ``FlowDirection``. Returns ``NOTDEFINED`` for non-canonical pairs.""" + a = getattr(port_a, "FlowDirection", None) or "NOTDEFINED" + b = getattr(port_b, "FlowDirection", None) or "NOTDEFINED" + return _DIRECTION_FROM_FLOW_PAIR.get((a, b), "NOTDEFINED") + + class System(bonsai.core.tool.System): @classmethod def get_system_props(cls) -> BIMSystemProperties: @@ -81,7 +100,7 @@ class System(bonsai.core.tool.System): # make sure obj.dimensions and .matrix_world has valid data bpy.context.view_layer.update() # need to make sure .ObjectPlacement is also updated when we're going to add ports - tool.Model.sync_object_ifc_position(obj) + tool.Geometry.commit_placement_if_moved(obj) mep_element = tool.Ifc.get_entity(obj) bbox = tool.Blender.get_object_bounding_box(obj) @@ -162,12 +181,12 @@ class System(bonsai.core.tool.System): return ifcopenshell.util.system.get_ports(element) @classmethod - def get_port_relating_element(cls, port: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: + def get_port_relating_element(cls, port: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]: if tool.Ifc.get_schema() == "IFC2X3": - element = port.ContainedIn[0].RelatedElement - else: - element = port.Nests[0].RelatingObject - return element + rel = port.ContainedIn[0] if port.ContainedIn else None + return rel.RelatedElement if rel else None + rel = port.Nests[0] if port.Nests else None + return rel.RelatingObject if rel else None @classmethod def get_port_predefined_type(cls, mep_element: ifcopenshell.entity_instance) -> str: @@ -280,31 +299,42 @@ class System(bonsai.core.tool.System): system_props = cls.get_system_props() return tool.Ifc.get_entity_by_id(system_props.active_system_id) + # Decoration-data cache, keyed on (decorator_cache_token, id(decorated_elements_set)). + _decoration_data_cache_key: tuple | None = None + _decoration_data_cache: dict[str, Any] | None = None + @classmethod def get_decoration_data(cls) -> dict[str, Any]: + from bonsai.bim.decorator_cache import get_decorator_cache_token + from bonsai.bim.module.system.data import ObjectSystemData, SystemDecorationData + + if not ObjectSystemData.is_loaded: + ObjectSystemData.load() + if not SystemDecorationData.is_loaded: + SystemDecorationData.load() + + token = get_decorator_cache_token() + key = (token, id(SystemDecorationData.data["decorated_elements"])) + if key == cls._decoration_data_cache_key and cls._decoration_data_cache is not None: + return cls._decoration_data_cache + + result = cls._build_decoration_data() + cls._decoration_data_cache_key = key + cls._decoration_data_cache = result + return result + + @classmethod + def _build_decoration_data(cls) -> dict[str, Any]: + from bonsai.bim.module.system.data import ObjectSystemData, SystemDecorationData + all_vertices = [] preview_edges = [] special_vertices = [] selected_edges = [] selected_vertices = [] - view3d_space = tool.Blender.get_viewport_context()["space_data"].region_3d - viewport_matrix = view3d_space.view_matrix.inverted() - viewport_y_axis = viewport_matrix.col[1].to_3d().normalized() - camera_pos = viewport_matrix.translation - dir_to_camera = lambda x: (camera_pos - x).normalized() - - def most_aligned_vector(a, vectors): - return max(vectors, key=lambda v: abs(a.dot(v))) - start_vert_i = 0 - if not ObjectSystemData.is_loaded: - ObjectSystemData.load() - - if not SystemDecorationData.is_loaded: - SystemDecorationData.load() - class FlowDirection(Enum): BACKWARD = -1 FORWARD = 1 @@ -458,6 +488,90 @@ class System(bonsai.core.tool.System): def is_mep_element(cls, element: ifcopenshell.entity_instance) -> bool: return element.is_a("IfcFlowSegment") or element.is_a("IfcFlowFitting") + @classmethod + def has_parametric_body(cls, element: ifcopenshell.entity_instance) -> bool: + """True when the MEP element's body representation is a profile sweep + (``IfcExtrudedAreaSolid`` for segments, ``IfcSweptDiskSolid`` for + fittings) — the shape the parametric edit + MEP action gizmos can + actually mutate. Tessellation- or brep-imported MEP elements return + False so their gizmos hide rather than offer edits the geometry + kernel can't honour.""" + import bonsai.tool as tool + + body = tool.Geometry.get_body_representation(element) + if body is None: + return False + for item in tool.Ifc.get().traverse(body): + if item.is_a("IfcExtrudedAreaSolid") or item.is_a("IfcSweptDiskSolid"): + return True + return False + + @classmethod + def walk_connected_mep_elements( + cls, start_element: ifcopenshell.entity_instance + ) -> list[ifcopenshell.entity_instance]: + """Return all MEP elements reachable from ``start_element`` via + ``IfcRelConnectsPorts`` in either direction, in BFS order with + ``start_element`` first. + + Only ``IfcFlowSegment`` and ``IfcFlowFitting`` instances are + returned; non-MEP neighbours reached via a fitting's port are + traversed but not collected. + """ + if not cls.is_mep_element(start_element): + return [] + result: list[ifcopenshell.entity_instance] = [] + visited: set[int] = set() + queue: deque[ifcopenshell.entity_instance] = deque([start_element]) + while queue: + element = queue.popleft() + if element.id() in visited: + continue + visited.add(element.id()) + if not cls.is_mep_element(element): + continue + result.append(element) + for port in cls.get_ports(element): + connected_port = cls.get_connected_port(port) + if connected_port is None: + continue + neighbor = cls.get_port_relating_element(connected_port) + if neighbor is None or neighbor.id() in visited: + continue + queue.append(neighbor) + return result + + @classmethod + def get_port_world_position(cls, port: ifcopenshell.entity_instance) -> Vector: + """World-space position of an ``IfcDistributionPort``. + + Follows the parent element's live ``matrix_world`` when available so + an uncommitted rotation doesn't drift from its ports; falls back to + the raw IFC placement otherwise.""" + placement = getattr(port, "ObjectPlacement", None) + if placement is None: + return Vector((0.0, 0.0, 0.0)) + port_ifc_matrix = Matrix(ifcopenshell.util.placement.get_local_placement(placement).tolist()) + + parent_element = cls.get_port_relating_element(port) + if parent_element is None: + return Vector(port_ifc_matrix.translation) + + parent_obj = tool.Ifc.get_object(parent_element) + if parent_obj is None: + return Vector(port_ifc_matrix.translation) + + parent_placement = getattr(parent_element, "ObjectPlacement", None) + if parent_placement is None: + return Vector(port_ifc_matrix.translation) + parent_ifc_matrix = Matrix(ifcopenshell.util.placement.get_local_placement(parent_placement).tolist()) + + try: + port_local_to_parent = parent_ifc_matrix.inverted() @ port_ifc_matrix + except ValueError: + return Vector(port_ifc_matrix.translation) + return (parent_obj.matrix_world @ port_local_to_parent).translation + @classmethod def get_flow_element_controls(cls, element: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]: if not element.HasControlElements: diff --git a/src/bonsai/bonsai/tool/unit.py b/src/bonsai/bonsai/tool/unit.py index 4c3e45b794..5bef7feae1 100644 --- a/src/bonsai/bonsai/tool/unit.py +++ b/src/bonsai/bonsai/tool/unit.py @@ -199,8 +199,8 @@ class Unit(bonsai.core.tool.Unit): if inches is None: inches = 0 - # If feet is negative, inches should also be negative (subtractive) - if feet < 0: + # If feet is negative (including -0), inches should also be negative (subtractive) + if math.copysign(1, feet) < 0: inches = -inches # Convert to meters diff --git a/src/bonsai/bonsai/tool/wall.py b/src/bonsai/bonsai/tool/wall.py new file mode 100644 index 0000000000..c982b15371 --- /dev/null +++ b/src/bonsai/bonsai/tool/wall.py @@ -0,0 +1,327 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Side-effect-free wall helpers — IFC reads and wall-axis geometry, callable from +gizmo lambdas without loading the wall's draft props. The world-space geometry helpers +are pure-math wrappers over ``bonsai.core.model``.""" + +from __future__ import annotations + +from collections import deque +from typing import TYPE_CHECKING, TypedDict + +import ifcopenshell +import ifcopenshell.util.element +import ifcopenshell.util.representation +import ifcopenshell.util.unit +from mathutils import Vector + +import bonsai.core.model +import bonsai.core.tool +import bonsai.tool as tool + +if TYPE_CHECKING: + import bpy + + +class WallGeometry(TypedDict): + anchor_x: float + length: float + height: float + x_angle: float + thickness: float + offset: float + + +class Wall(bonsai.core.tool.Wall): + @classmethod + def get_length_and_height(cls, wall: ifcopenshell.entity_instance) -> tuple[float, float] | None: + """SI length and vertical height of a LAYER2 extruded wall, or ``None`` for + non-parametric bodies (sweeps, brep, non-extrusion booleans).""" + representation = tool.Geometry.get_body_representation(wall) + if not representation: + return None + extrusion = tool.Model.get_extrusion(representation) + if not extrusion: + return None + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + p1, p2 = ifcopenshell.util.representation.get_reference_line(wall) + x_angle = tool.Model.get_existing_x_angle(extrusion) + return bonsai.core.model.length_and_height_from_extrusion( + extrusion_depth=extrusion.Depth, + x_angle=x_angle, + reference_line_x_extent=p2[0] - p1[0], + unit_scale=unit_scale, + ) + + @classmethod + def get_axis_local_extent(cls, wall: ifcopenshell.entity_instance) -> tuple[float, float] | None: + """``(min_x, max_x)`` of the wall's IFC reference line in wall-local SI metres, + or ``None``. Anchors wall-edge gizmos at IFC-authoritative ends — ``obj.bound_box`` + would drift on trimmed walls or walls with end openings.""" + representation = tool.Geometry.get_body_representation(wall) + if not representation: + return None + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + p1, p2 = ifcopenshell.util.representation.get_reference_line(wall) + x1, x2 = p1[0] * unit_scale, p2[0] * unit_scale + return (min(x1, x2), max(x1, x2)) + + @classmethod + def get_x_angle(cls, wall: ifcopenshell.entity_instance) -> float | None: + """Slanted-extrusion angle (radians) of a LAYER2 wall, zero for vertical walls, + ``None`` for non-parametric bodies. Callers that assume wall-local Z == world Z + must gate on this being zero.""" + representation = tool.Geometry.get_body_representation(wall) + if not representation: + return None + extrusion = tool.Model.get_extrusion(representation) + if not extrusion: + return None + return tool.Model.get_existing_x_angle(extrusion) + + @classmethod + def read_geometry(cls, obj: bpy.types.Object) -> WallGeometry | None: + """Live wall geometry from IFC in SI metres/radians, or ``None`` for + non-path-connectable walls. Shared by gizmo positioning and draft + initialisation. Fillet-corner walls carry their chord axis as the + reference line and report zero thickness / offset (material was + unassigned at construction); callers that need a layer-driven thickness + must gate on ``tool.Parametric.is_wall`` upstream.""" + element = tool.Ifc.get_entity(obj) + if not element or not tool.Parametric.is_path_connectable_wall(element): + return None + representation = tool.Geometry.get_body_representation(element) + if not representation: + return None + extrusion = tool.Model.get_extrusion(representation) + if not extrusion: + return None + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + p1, p2 = ifcopenshell.util.representation.get_reference_line(element) + layer_params = tool.Model.get_material_layer_parameters(element) + x_angle = tool.Model.get_existing_x_angle(extrusion) + return { + "anchor_x": p1[0] * unit_scale, + "length": (p2[0] - p1[0]) * unit_scale, + "height": bonsai.core.model.vertical_height_from_extrusion_depth(extrusion.Depth * unit_scale, x_angle), + "x_angle": x_angle, + "thickness": layer_params["thickness"], + "offset": layer_params["offset"], + } + + @classmethod + def collinear_boundary_world(cls, seg_a: tuple[Vector, Vector], seg_b: tuple[Vector, Vector]) -> Vector: + """World-space midpoint of the closest endpoint pair across two wall axis segments — + the anchor for Merge/Unjoin gizmos on collinear or already-joined walls.""" + return Vector( + bonsai.core.model.closest_endpoint_midpoint( + (tuple(seg_a[0]), tuple(seg_a[1])), + (tuple(seg_b[0]), tuple(seg_b[1])), + ) + ) + + @classmethod + def path_connection_location_world( + cls, + seg_self: tuple[Vector, Vector], + self_conn_type: str, + seg_other: tuple[Vector, Vector], + other_conn_type: str, + parallel_threshold: float = bonsai.core.model.PARALLEL_DOT_THRESHOLD, + ) -> Vector: + """World-space physical join point of an ``IfcRelConnectsPathElements`` — an + endpoint for end-connected walls, the axis intersection for ATPATH junctions.""" + return Vector( + bonsai.core.model.compute_path_connection_location( + (tuple(seg_self[0]), tuple(seg_self[1])), + self_conn_type, + (tuple(seg_other[0]), tuple(seg_other[1])), + other_conn_type, + parallel_threshold, + ) + ) + + @classmethod + def validate_for_parametric_edit(cls, obj: bpy.types.Object) -> str | None: + """``None`` if the wall is parametrically editable, else a user-facing string naming + the specific gap so the user can fix the precise blocker.""" + element = tool.Ifc.get_entity(obj) + if not element: + return "Object is not an IFC element." + if not element.is_a("IfcWall"): + return f"Object is an {element.is_a()}, not an IfcWall." + if tool.Model.get_usage_type(element) != "LAYER2": + return ( + "Wall has no IfcMaterialLayerSetUsage with LayerSetDirection AXIS2 (required for parametric editing)." + ) + representation = tool.Geometry.get_body_representation(element) + if not representation: + return "Wall has no Model/Body/MODEL_VIEW representation to drive parametric dimensions." + if not tool.Model.get_extrusion(representation): + return ( + "Wall body is not an IfcExtrudedAreaSolid " + "(e.g. a brep mesh or boolean result without a base extrusion)." + ) + return None + + @classmethod + def has_layer2_usage(cls, wall: ifcopenshell.entity_instance) -> bool: + """True iff ``wall`` is a LAYER2 parametric wall (has ``IfcMaterialLayerSetUsage`` + with ``LayerSetDirection == AXIS2``). Required by every parametric wall edit — + non-LAYER2 walls (brep / freeform bodies) cannot be driven by axis + thickness.""" + return tool.Model.get_usage_type(wall) == "LAYER2" + + @classmethod + def is_straight_axis(cls, wall: ifcopenshell.entity_instance) -> bool: + """True iff the wall's Axis representation is a single straight line segment. + + Curved-axis walls (e.g. a fillet corner inserted between two straight walls) + report ``False`` so callers gate them out of operations that assume a straight + reference line. The check inspects the ``Plan/Axis/GRAPH_VIEW`` representation + when present; falls back to True when no Axis representation exists (the + ``Body`` extrusion alone is implicitly straight).""" + axis_rep = ifcopenshell.util.representation.get_representation(wall, "Plan", "Axis", "GRAPH_VIEW") + if axis_rep is None or not axis_rep.Items: + return True + for item in axis_rep.Items: + if item.is_a("IfcPolyline"): + if len(item.Points) != 2: + return False + elif item.is_a("IfcIndexedPolyCurve"): + # An ``IfcIndexedPolyCurve`` is straight only when (a) its + # ``Points`` list holds exactly two points and (b) it has no + # ``Segments`` or only ``IfcLineIndex`` segments. Any ``IfcArcIndex`` + # makes it curved. + segments = getattr(item, "Segments", None) + if segments: + for seg in segments: + if seg.is_a("IfcArcIndex"): + return False + point_list = item.Points + point_coords = getattr(point_list, "CoordList", None) if point_list else None + if point_coords and len(point_coords) > 2: + return False + else: + # Trimmed curve, composite curve, B-spline — definitely curved. + return False + return True + + @classmethod + def get_world_reference_line(cls, obj: bpy.types.Object) -> tuple[Vector, Vector] | None: + """World-space endpoints of the wall's IFC reference line, in Blender units. + + Returns ``(p1, p2)`` as 3D vectors with the wall's local Z preserved. + Returns ``None`` when the wall has no IFC element or no IFC Axis + representation. Anchors to the IFC reference line, not the mesh bound + box, so it stays correct when the mesh is stale or trimmed past the + IFC axis endpoints.""" + element = tool.Ifc.get_entity(obj) + if element is None or not tool.Geometry.has_axis_representation(element): + return None + p1, p2 = ifcopenshell.util.representation.get_reference_line(element) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + local_p1 = Vector((p1[0] * unit_scale, p1[1] * unit_scale, 0.0)) + local_p2 = Vector((p2[0] * unit_scale, p2[1] * unit_scale, 0.0)) + return obj.matrix_world @ local_p1, obj.matrix_world @ local_p2 + + @classmethod + def walk_connected_walls( + cls, + start_element: ifcopenshell.entity_instance, + node_cap: int = 5000, + ) -> list[ifcopenshell.entity_instance]: + """BFS over ``IfcRelConnectsPathElements`` from ``start_element``. + + Returns every ``IfcWall`` reachable in either direction (relating / + related side of the relation) in BFS order with ``start_element`` + first. Stops when ``node_cap`` walls have been visited so a corrupt + or massive network can't lock up a draw callback. Non-wall path + elements (e.g. ``IfcRoof``, ``IfcSlab``) are traversed but not + collected — they may bridge two disjoint wall runs. + + Mirror of ``tool.System.walk_connected_mep_elements``.""" + if not start_element.is_a("IfcWall"): + return [] + result: list[ifcopenshell.entity_instance] = [] + visited: set[int] = set() + queue: deque[ifcopenshell.entity_instance] = deque([start_element]) + while queue and len(visited) < node_cap: + element = queue.popleft() + if element.id() in visited: + continue + visited.add(element.id()) + if element.is_a("IfcWall"): + result.append(element) + # ``ConnectedTo`` / ``ConnectedFrom`` are the IFC inverse + # attributes that expose the relations where this element + # is the relating / related side respectively. + for rel in getattr(element, "ConnectedTo", []) or (): + if rel.is_a("IfcRelConnectsPathElements"): + neighbor = rel.RelatedElement + if neighbor is not None and neighbor.id() not in visited: + queue.append(neighbor) + for rel in getattr(element, "ConnectedFrom", []) or (): + if rel.is_a("IfcRelConnectsPathElements"): + neighbor = rel.RelatingElement + if neighbor is not None and neighbor.id() not in visited: + queue.append(neighbor) + return result + + @classmethod + def compute_wall_fillet_geometry( + cls, + wall_a_obj: bpy.types.Object, + wall_b_obj: bpy.types.Object, + radius: float, + arc_resolution: int = bonsai.core.model.FILLET_DEFAULT_ARC_RESOLUTION, + ) -> dict | None: + """Compute fillet geometry between two walls in world space. + + Returns a dict augmented with ``profile_thickness`` and ``height`` from + the active (A) wall's LAYER2 parameters, plus ``wall_type_id`` and + ``x_angle``. Returns ``None`` when either wall lacks a reference line + or LAYER2 usage.""" + axis_a = cls.get_world_reference_line(wall_a_obj) + axis_b = cls.get_world_reference_line(wall_b_obj) + if axis_a is None or axis_b is None: + return None + + wall_a = tool.Ifc.get_entity(wall_a_obj) + if wall_a is None or not cls.has_layer2_usage(wall_a): + return None + + seg_a = ((axis_a[0].x, axis_a[0].y, axis_a[0].z), (axis_a[1].x, axis_a[1].y, axis_a[1].z)) + seg_b = ((axis_b[0].x, axis_b[0].y, axis_b[0].z), (axis_b[1].x, axis_b[1].y, axis_b[1].z)) + result = bonsai.core.model.compute_fillet_polylines(seg_a, seg_b, radius, arc_resolution) + + layers = tool.Model.get_material_layer_parameters(wall_a) + length_height = cls.get_length_and_height(wall_a) + wall_type = ifcopenshell.util.element.get_type(wall_a) + result.update( + { + "profile_thickness": layers["thickness"], + "profile_offset": layers["offset"], + "height": length_height[1] if length_height else None, + "x_angle": cls.get_x_angle(wall_a) or 0.0, + "wall_type_id": wall_type.id() if wall_type else None, + } + ) + return result diff --git a/src/bonsai/docs/guides/authoring/basic_modeling/creating_walls.rst b/src/bonsai/docs/guides/authoring/basic_modeling/creating_walls.rst index 33c4bd766a..6298cedda3 100644 --- a/src/bonsai/docs/guides/authoring/basic_modeling/creating_walls.rst +++ b/src/bonsai/docs/guides/authoring/basic_modeling/creating_walls.rst @@ -51,6 +51,62 @@ To use these tools: 2. Use the appropriate shortcut or select the tool from the top bar. 3. Follow the on-screen prompts or adjust parameters as needed. +Interactive Parametric Editing +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Selected walls expose an in-viewport parametric edit mode that mirrors the door / +window / stair pen-icon UI: + +1. Select a single wall. A pen (Edit Wall) icon appears next to the wall in the + 3D viewport, and a matching ``Edit Wall`` button is available in the + ``Parametric Geometry`` tab of the N panel. +2. Click the pen icon (or the panel button) to enter edit mode. Dimension + gizmos for length, height, slope (x-angle) and the layer offset baseline + appear around the wall. +3. Drag any handle to update the value. Dragging only modifies the in-progress + draft — the IFC file is not touched until you commit, so dragging a length + handle through many intermediate values produces zero extra IFC entities. +4. Click the green ✓ icon to commit; click the red ✗ to discard. Pressing the + ✓ icon on a wall that hasn't been dragged is a true byte-identical no-op — + the IFC file is unchanged. + +While editing, additional gizmos surface based on context: + +- **Cycle Baseline**: cycles the layer offset baseline (Exterior → Centreline → + Interior). Shift+click cycles in reverse. +- **3D-cursor scissors**: appears when the 3D cursor sits on the wall axis; + clicking splits the wall at the cursor's projected X. +- **3D-cursor extend (horizontal)**: appears when the 3D cursor sits beyond the + wall axis; clicking extends the wall to the cursor's projected X. +- **3D-cursor extend (vertical)**: appears when the 3D cursor sits above / + below the wall; clicking extends the wall's height to the cursor's Z. +- **Rotate 90°**: rotates the wall around its Z axis. +- **Show / hide openings**: toggles opening fill visibility (doors and windows). + +When two walls are selected, the gizmo switches to a state-aware icon at their +common point: + +- Already joined → an Unjoin icon at the shared corner. +- Collinear (same axis line) → a Merge icon at the boundary midpoint. +- Joinable corner → a Join icon at the floor + an Extend-To-Wall icon at the + active wall's top. + +When a wall and a slab (LAYER3 element) are selected, an Extend-Vertically icon +appears at the wall's origin / slab elevation; clicking dispatches +``bim.extend_walls_to_underside``. + +When a wall and a non-wall, non-slab object are selected, an Add-Opening icon +appears above the wall at the other object's projected X. + +Auto-commit on save +~~~~~~~~~~~~~~~~~~~ + +Pressing Ctrl+S (or running ``bim.save_project``) while any wall is mid-edit +flushes every pending parametric draft first — the same Apply-Wall-Edits the ✓ +icon performs, scoped per wall. The IFC saved on disk reflects the values the +user dragged, not the snapshot taken when edit mode was entered. Each commit +produces its own undo entry, so Ctrl+Z walks back through commits individually. + Aligning Walls ^^^^^^^^^^^^^^ diff --git a/src/bonsai/docs/guides/development/maintenance.rst b/src/bonsai/docs/guides/development/maintenance.rst index 5550f7ba6c..f08a003fd5 100644 --- a/src/bonsai/docs/guides/development/maintenance.rst +++ b/src/bonsai/docs/guides/development/maintenance.rst @@ -61,6 +61,8 @@ When Blender ships with a new Python version: - What to update * - ``.github/workflows/ci-lint.yaml`` - ``MIN_BLENDER_PY_VERSION`` + * - ``.github/scripts/publish-bonsai-releases.py`` + - ``CURRENT_PYTHON_VERSION`` * - ``src/bonsai/Makefile`` - ``SUPPORTED_PYVERSIONS`` * - ``src/bonsai/scripts/dev_environment.py`` @@ -73,6 +75,18 @@ Notes: - Typically all packages are released at once using the same version schema - The ``README.md`` badges can serve as a visual reference for what versions have been released +- Corrective Release (if needed after a standard release): + + - Create a new branch from the release tag (e.g., from the ``ifcopenshell-0.8.5`` tag) + - Update ``VERSION`` with the ``-post1`` suffix (e.g., ``0.8.5-post1``, **not** ``.post1``) + - The hyphen is required for semantic versioning compliance; Blender will not process ``.post1`` suffixes correctly + - Follow the standard release process for the corrective version + +- Multiple Blender Python Versions: + + - Blender does not allow multiple builds for the same platform with different Python versions (e.g., cannot have both ``bonsai_py311-0.8.5-windows-x64.zip`` and ``bonsai_py313-0.8.5-windows-x64.zip``) + - Workaround: publish different Python versions as different extension versions (e.g., py313 as ``0.8.5`` and py311 as ``0.8.5-post1``) + - Set the maximum Blender version on the Blender extensions platform UI to prevent conflicts (e.g., set max version ``5.1.0`` for ``0.8.5-post1``, which restricts it to versions below 5.1.0) Things to update: @@ -96,10 +110,13 @@ Things to update: - ``.github/workflows/ci-ifcsverchok.yml`` - release ifcsverchok Blender add-on in GitHub releases - ``.github/workflows/ci-ifctester-pypi.yml`` - release `ifctester `_ to PyPI - ``.github/workflows/ci-pyodide-wasm-release.yml`` - release pyodide wasm wheel to `wasm-wheels `_ -- Release Bonsai Blender extension - zip files from ci-bonsai.yml releases should be uploaded manually to `Blender extensions platform `_ +- ``.github/workflows/publish-bonsai-releases.yml`` - publish Bonsai Blender extension to `Blender extensions platform `_ + + - ❗ Requires ``BLENDER_EXTENSIONS_TOKEN`` secret to be set - ❗ not yet configured + - Publishing documentation and websites (see `website `_ repository): - `ifcopenshell-docs.yml` - builds and publishes IfcOpenShell documentation to `docs.ifcopenshell.org `_ (`ifcopenshell_org_docs `_ repo) - `bonsai-docs.yml` - builds and publishes Bonsai documentation to `docs.bonsaibim.org `_ (`bonsaibim_org_docs `_ repo) - - `main.yml` - publishes `bonsaibim.org `_ (`bonsaibim_org_static_html `_ repo) and `ifcopenshell.org `_ (`ifcopenshell_org_static_html `_ repo) + - `publish-websites.yml` - publishes `bonsaibim.org `_ (`bonsaibim_org_static_html `_ repo) and `ifcopenshell.org `_ (`ifcopenshell_org_static_html `_ repo) - ``VERSION`` to the release version - **UPDATE THIS LAST** as all workflows above typically depend on it to set the version correctly diff --git a/src/bonsai/docs/reference/project_overview/project_info.rst b/src/bonsai/docs/reference/project_overview/project_info.rst index f2fe105f8c..ffe33427a6 100644 --- a/src/bonsai/docs/reference/project_overview/project_info.rst +++ b/src/bonsai/docs/reference/project_overview/project_info.rst @@ -58,7 +58,7 @@ Fields Class** based on the IFC Schema version. **Unit System** - Choose between metric and imperial units of measurement when creating a project. + Choose between metric and imperial units of measurement when creating a project. Project data is stored in this Unit System and displayed according to e.g. Length Unit, Area Unit, Volume Unit. Properly changing the Unit System after project creation requires conversion. See `Blender Manual : Scene Properties : Units `_ for a description of changing the display units e.g. from Feet to Adaptive (enable Separate Units option) for Feet-and-Inches. **Length Unit** Depending on the unit system, choose the default unit to be used for all length measurements. Lengths are used for moving objects around in the 3D scene, as well as lengths, widths, height, and depth quantity take-off data. diff --git a/src/bonsai/runpytest.py b/src/bonsai/runpytest.py index 9a00ea69b0..88e1472095 100755 --- a/src/bonsai/runpytest.py +++ b/src/bonsai/runpytest.py @@ -17,18 +17,46 @@ # along with Bonsai. If not, see . """ -Requires pytest installed under blender +Requires pytest installed under blender. -Usage: `blender -b -P runpytest.py -- ARGS` +Usage: + blender -b -P runpytest.py -- ARGS + +Alternative (when the calling shell strips or reorders the ``--`` separator +before it reaches Blender — observed with some PowerShell / wrapper-script +invocations on Windows): pass the same pytest args via the +``BONSAI_TEST_ARGS`` environment variable as a single shell-quoted string +and invoke without ``--``:: + + $env:BONSAI_TEST_ARGS = "test/bim/ -x -q" + blender -b -P runpytest.py """ +import os +import shlex import sys import pytest argv = [__file__] -if "--" in sys.argv: +env_args = os.environ.get("BONSAI_TEST_ARGS", "") +if env_args: + # POSIX-style quoting works on all three OSes — env var values are + # literal strings (no shell evaluation when Python reads them), and + # POSIX quoting (``'foo "bar baz" qux'`` → three tokens, quotes stripped) + # matches what most docs and examples use. + argv += shlex.split(env_args) + # On the env-var path the args never appear in Blender's argv at all, + # so any pytest plugin that reads ``sys.argv`` directly (instead of + # going through pytest's API) would otherwise see only Blender's own + # ``-b -P runpytest.py`` and miss the test args entirely. Shadow argv + # so those plugins see the pytest-shaped view they expect. + sys.argv = list(argv) +elif "--" in sys.argv: + # The traditional path: Blender forwards everything after ``--`` to the + # script via ``sys.argv``. ``sys.argv`` is deliberately left as Blender + # set it — pre-existing behavior, preserved. i = sys.argv.index("--") argv += sys.argv[i + 1 :] diff --git a/src/bonsai/test/bim/feature/geometry.feature b/src/bonsai/test/bim/feature/geometry.feature index 3bbf22a5c2..1236f416c8 100644 --- a/src/bonsai/test/bim/feature/geometry.feature +++ b/src/bonsai/test/bim/feature/geometry.feature @@ -285,6 +285,32 @@ Scenario: Override duplicate move - without active IFC data Then the object "Cube" exists And the object "Cube.001" exists +Scenario: Override duplicate move - non-IFC objects inside an IFC project + Given an empty IFC project + And I add a cube + And the object "Cube" is selected + When I duplicate the selected objects + Then the object "Cube" exists + And the object "Cube.001" exists + And the object "Cube.001" is selected + +Scenario: Override duplicate move - mixed IFC and non-IFC selection + Given an empty IFC project + And I add a cube + And the object "Cube" is selected + And I look at the "Class" panel + And I set the "Products" property to "IfcElement" + And I set the "Class" property to "IfcWall" + And I click "Assign IFC Class" + And I add a cube + And the object "IfcWall/Cube" is selected + And additionally the object "Cube" is selected + When I duplicate the selected objects + Then the object "IfcWall/Cube.001" exists + And the object "IfcWall/Cube.001" is selected + And the object "Cube.001" exists + And the object "Cube.001" is selected + Scenario: Override duplicate move - with active IFC data Given an empty IFC project And I add a cube diff --git a/src/bonsai/test/bim/feature/model.feature b/src/bonsai/test/bim/feature/model.feature index 064620a574..7bc0c667d7 100644 --- a/src/bonsai/test/bim/feature/model.feature +++ b/src/bonsai/test/bim/feature/model.feature @@ -285,6 +285,7 @@ Scenario: Split a wall which has a flipped door And the object "IfcWall/Wall" is selected And I press "bim.hotkey(hotkey='S_K')" Then the object "IfcDoor/Door" is at "8.01,0.1,0" + And the object "IfcWall/Wall.001" is filled by "IfcDoor/Door" Scenario: Offset walls Given an empty IFC project @@ -673,6 +674,120 @@ Scenario: Create door type based on door modifier, add an occurrence of it and e And I press "bim.finish_editing_door()" Then nothing happens +Scenario: Saving with a door mid-edit auto-commits the draft value to the IFC pset + Given an empty IFC project + And I trigger "Add Element" + And I set the "Class" property to "IfcDoorType" + And I set the "Predefined Type" property to "DOOR" + And I set the "Representation" property to "Door" + When I click "OK" + And I press "bim.add_occurrence" + And I press "bim.enable_editing_door()" + And I set "active_object.BIMDoorProperties.overall_height" to "2.5" + Then "active_object.BIMDoorProperties.is_editing" is "True" + When I press "bim.save_project(filepath='{temp_project_path}', should_save_as=True)" + Then "active_object.BIMDoorProperties.is_editing" is "False" + # BBIM_ psets store project units, not raw Blender SI. The empty project + # used in an_empty_blender_session is METRIC_MM, so 2.5 m → 2500 mm in the pset. + And the variable "saved_height" is "__import__('json').loads(ifcopenshell.util.element.get_pset({ifc}.by_type('IfcDoor')[0], 'BBIM_Door', 'Data'))['overall_height']" + And the variable "saved_height" equals "2500.0" + +Scenario: Saving with no parametric edits in progress leaves the door pset unchanged + Given an empty IFC project + And I trigger "Add Element" + And I set the "Class" property to "IfcDoorType" + And I set the "Predefined Type" property to "DOOR" + And I set the "Representation" property to "Door" + When I click "OK" + And I press "bim.add_occurrence" + And the variable "pre_save_height" is "__import__('json').loads(ifcopenshell.util.element.get_pset({ifc}.by_type('IfcDoor')[0], 'BBIM_Door', 'Data'))['overall_height']" + When I press "bim.save_project(filepath='{temp_project_path}', should_save_as=True)" + Then the variable "post_save_height" is "__import__('json').loads(ifcopenshell.util.element.get_pset({ifc}.by_type('IfcDoor')[0], 'BBIM_Door', 'Data'))['overall_height']" + And the variable "post_save_height" equals "{pre_save_height}" + +Scenario: Saving with a wall mid-edit auto-commits the draft to IFC + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_occurrence" + And the object "IfcWall/Wall" is selected + And I press "bim.enable_editing_wall()" + Then "active_object.BIMWallProperties.is_editing" is "True" + When I press "bim.save_project(filepath='{temp_project_path}', should_save_as=True)" + Then "active_object.BIMWallProperties.is_editing" is "False" + +Scenario: Enabling and finishing a wall edit with no drag is a no-op + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_occurrence" + And the object "IfcWall/Wall" is selected + And the variable "entity_count_before" is "len(list({ifc}))" + When I press "bim.enable_editing_wall()" + And I press "bim.finish_editing_wall()" + Then "active_object.BIMWallProperties.is_editing" is "False" + And the variable "entity_count_after" is "len(list({ifc}))" + And the variable "entity_count_after" equals "{entity_count_before}" + +Scenario: Cancelling a wall edit clears is_editing + Given an empty IFC project + And I add a cube + And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType" + And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType" + And I press "bim.assign_class" + And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" + And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{cube}" + And I press "bim.add_occurrence" + And the object "IfcWall/Wall" is selected + And I press "bim.enable_editing_wall()" + When I press "bim.cancel_editing_wall()" + Then "active_object.BIMWallProperties.is_editing" is "False" + +Scenario: Wall parametric edit works on IFC2X3 projects + Given an empty IFC2X3 project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_occurrence" + And the object "IfcWall/Wall" is selected + When I press "bim.enable_editing_wall()" + Then "active_object.BIMWallProperties.is_editing" is "True" + When I press "bim.finish_editing_wall()" + Then "active_object.BIMWallProperties.is_editing" is "False" + +Scenario: Rotate a wall 90° via bim.rotate_wall_90 + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_occurrence" + And the object "IfcWall/Wall" is selected + When I press "bim.rotate_wall_90()" + Then the object "IfcWall/Wall" dimensions are "1,0.1,3" + And the object "IfcWall/Wall" bottom left corner is at "0,0,0" + And the object "IfcWall/Wall" top right corner is at "-0.1,1,3" + +Scenario: Splitting a wall with another wall mid-edit commits the pending edit first + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_occurrence" + And the object "IfcWall/Wall" is selected + And I press "bim.enable_editing_wall()" + Then "active_object.BIMWallProperties.is_editing" is "True" + When I press "bim.split_wall()" + Then "active_object.BIMWallProperties.is_editing" is "False" + Scenario: Create a door, undo and create a new door Given an empty IFC project And I prepare to undo diff --git a/src/bonsai/test/bim/module/drawing/test_dimension_gizmo_priority.py b/src/bonsai/test/bim/module/drawing/test_dimension_gizmo_priority.py new file mode 100644 index 0000000000..c3d30e4678 --- /dev/null +++ b/src/bonsai/test/bim/module/drawing/test_dimension_gizmo_priority.py @@ -0,0 +1,100 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Regression guard: overlapping distance gizmos must let the smaller one win. + +When two ``GizmoDimension`` instances overlap on screen (e.g. a short dimension +nested inside a longer one along the same axis), the longer one's hit box fully +contains the shorter one's. Without a depth bias the longer one wins the GPU +select tie-break and the shorter one becomes unreachable. + +``GizmoDimension.set_dimension_length`` writes ``select_bias = -dimension_length`` +so the smaller one writes a higher (less-negative) bias and wins. The longer one +stays clickable at its exposed ends regardless of bias. + +We call ``set_dimension_length`` as an unbound method on a ``SimpleNamespace`` +fake ``self``. Its body only *writes* attributes (``_display_value``, +``_dimension_length``, ``select_bias``), so it doesn't need a real +``bpy.types.Gizmo`` instance — those only exist inside a registered +``GizmoGroup`` and aren't constructible in a headless test.""" + +import types +from types import SimpleNamespace + +import bpy +import pytest + +from bonsai.bim.module.drawing.gizmos import GizmoDimension + +pytestmark = pytest.mark.drawing + + +@pytest.fixture(autouse=True) +def _require_real_bpy(): + if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"): + pytest.skip("requires real Blender (bpy is mocked or absent)") + + +def test_smaller_dimension_wins_select_bias(): + small = SimpleNamespace() + large = SimpleNamespace() + GizmoDimension.set_dimension_length(small, 0.077) + GizmoDimension.set_dimension_length(large, 0.109) + assert small.select_bias > large.select_bias + + +@pytest.mark.parametrize( + "lengths", + [ + [0.0, 0.05, 0.077, 0.109, 1.0, 5.0, 10.0], + [0.001, 0.5, 2.5, 100.0, 9999.0], + ], +) +def test_select_bias_is_non_increasing_in_length(lengths): + """A monotonic mapping is all Blender's GPU select needs to break the tie.""" + biases = [] + for length in lengths: + gizmo = SimpleNamespace() + GizmoDimension.set_dimension_length(gizmo, length) + biases.append(gizmo.select_bias) + for prev, curr in zip(biases, biases[1:]): + assert prev >= curr, f"select_bias must be non-increasing in length, got {biases}" + + +def test_negative_length_uses_absolute_value_for_bias(): + """Negative dimension values (e.g. inverted angles) clamp to abs() for hit-box scaling; + select_bias follows the same clamped magnitude so signed-direction gizmos still + obey the smaller-wins rule against their positive-sided peers.""" + positive = SimpleNamespace() + negative = SimpleNamespace() + GizmoDimension.set_dimension_length(positive, 0.5) + GizmoDimension.set_dimension_length(negative, -0.5) + assert positive.select_bias == negative.select_bias + + +def test_nan_and_inf_length_falls_back_to_zero_bias(): + """Invalid inputs are coerced to 0.0 before the bias is written, so a malformed + update can't push a gizmo arbitrarily far forward or backward in the select buffer.""" + import math + + for bad in (math.nan, math.inf, -math.inf, "not a number"): + gizmo = SimpleNamespace() + GizmoDimension.set_dimension_length(gizmo, bad) + assert gizmo.select_bias == 0.0 diff --git a/src/bonsai/test/bim/module/drawing/test_gizmos.py b/src/bonsai/test/bim/module/drawing/test_gizmos.py new file mode 100644 index 0000000000..cc781cd118 --- /dev/null +++ b/src/bonsai/test/bim/module/drawing/test_gizmos.py @@ -0,0 +1,54 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +import types +from types import SimpleNamespace + +import bpy +import pytest + +from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig + +pytestmark = pytest.mark.drawing + + +@pytest.fixture(autouse=True) +def _require_real_bpy(): + if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"): + pytest.skip("requires real Blender (bpy is mocked or absent)") + + +def test_text_formatter_defaults_to_none(): + config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0)) + assert config.text_formatter is None + + +def test_text_formatter_field_stores_callable(): + formatter = lambda props, value: f"{value:.2f}m" # noqa: E731 + config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0), text_formatter=formatter) + assert config.text_formatter is not None + assert callable(config.text_formatter) + + +def test_text_formatter_receives_props_and_value(): + formatter = lambda props, value: f"{props.label}={value}" # noqa: E731 + config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0), text_formatter=formatter) + props = SimpleNamespace(label="L") + assert config.text_formatter(props, 3.14) == "L=3.14" diff --git a/src/bonsai/test/bim/module/geometry/__init__.py b/src/bonsai/test/bim/module/geometry/__init__.py new file mode 100644 index 0000000000..fa692422fc --- /dev/null +++ b/src/bonsai/test/bim/module/geometry/__init__.py @@ -0,0 +1,17 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2021 Dion Moult +# +# 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 . diff --git a/src/bonsai/test/bim/module/geometry/test_shape_aspects_forward_compat.py b/src/bonsai/test/bim/module/geometry/test_shape_aspects_forward_compat.py new file mode 100644 index 0000000000..ecfd0c9621 --- /dev/null +++ b/src/bonsai/test/bim/module/geometry/test_shape_aspects_forward_compat.py @@ -0,0 +1,61 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Forward-compat AST contract: ``HasShapeAspects`` is an IFC4+ inverse; +direct attribute access raises ``AttributeError`` on pre-IFC4 entity +instances. Production code must read it through ``getattr`` so the +absence in earlier schemas degrades to an empty iterable.""" + +import ast +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.geometry + + +BONSAI_ROOT = Path(__file__).parent.parent.parent.parent.parent / "bonsai" +PRODUCTION_DIRS = (BONSAI_ROOT / "bim", BONSAI_ROOT / "tool", BONSAI_ROOT / "core") + +ATTR_NAME = "HasShapeAspects" + + +def _iter_production_sources(): + for root in PRODUCTION_DIRS: + yield from root.rglob("*.py") + + +def test_has_shape_aspects_access_uses_getattr_guard(): + """Every read of ``HasShapeAspects`` in production code must go through + ``getattr(, "HasShapeAspects", )`` so files using + schemas that omit the inverse return the default instead of raising.""" + offenders = [] + for source in _iter_production_sources(): + tree = ast.parse(source.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.Attribute) and node.attr == ATTR_NAME: + offenders.append(f"{source.relative_to(BONSAI_ROOT.parent)}:{node.lineno}") + if offenders: + joined = "\n ".join(sorted(offenders)) + pytest.fail( + f"Direct .{ATTR_NAME} attribute access in production code:\n {joined}\n" + f"Wrap with getattr(, '{ATTR_NAME}', ()) so pre-IFC4 schemas " + f"do not raise AttributeError." + ) diff --git a/src/bonsai/test/bim/module/model/__init__.py b/src/bonsai/test/bim/module/model/__init__.py new file mode 100644 index 0000000000..023d474feb --- /dev/null +++ b/src/bonsai/test/bim/module/model/__init__.py @@ -0,0 +1,19 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. diff --git a/src/bonsai/test/bim/module/model/conftest.py b/src/bonsai/test/bim/module/model/conftest.py new file mode 100644 index 0000000000..f312684cdd --- /dev/null +++ b/src/bonsai/test/bim/module/model/conftest.py @@ -0,0 +1,228 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Shared fixtures and factories for ``test/bim/module/model/`` gizmo and +decorator tests. + +The boundary between Blender / IFC / Bonsai's ``tool.*`` layer is patched +identically across many model-test files (viewport-state, selection, IFC +entity lookup, modifier predicates, view-camera state). The ``patched_tool`` +fixture below centralises that patch stack so each test names only the +boundary methods it cares about; everything else is left to production. + +Factory helpers (``make_obj``, ``make_element``, ``make_context``, +``make_ifc_file``) replace near-identical local helpers that previously +lived in each file. + +When to use these fixtures in a new test file: + +- Adding a gizmo / decorator test that patches ``tool.Blender`` or + ``tool.Ifc`` boundary methods? Request the ``patched_tool`` fixture + as a test parameter and call it as a context-manager factory. +- Need a stub ``bpy.types.Object`` / ``ifcopenshell.entity_instance`` / + ``poll()`` context / ``ifcopenshell.file``? Import the matching factory + from this module rather than re-rolling locally. +- Need to reset module-level state (e.g. a decorator cache token) between + tests? Define an ``@pytest.fixture(autouse=True)`` reset in the test + file itself — these stay file-local because they target state specific + to one decorator/module and globalising the reset would surprise + unrelated tests. + +Layout note: pure helpers (``make_*``) live alongside the fixture in this +file rather than a sibling ``test_utils.py``. pytest's documented role for +``conftest.py`` is fixtures, so this is a mild convention bend — kept here +because the helper count is small and the dependencies (``tool``, ``Mock``) +already need to be imported for the fixture itself. Split into a separate +module if the helper count grows past ~6 or any helper picks up its own +non-trivial dependencies.""" + +import contextlib +import types +from types import SimpleNamespace +from unittest.mock import MagicMock, Mock, patch + +import bpy +import ifcopenshell +import pytest + +from bonsai import tool + + +@pytest.fixture(autouse=True) +def _require_real_bpy(): + """Skip every test in this directory when ``bpy`` is mocked or absent. + + The model gizmo / decorator suite reaches into Blender's RNA layer + (``bpy.types.Operator``, registered ``bl_idname`` lookups, ``Modifier`` + predicates) that ``Mock`` cannot impersonate, so a tool-lane run with a + stubbed ``bpy`` would error rather than meaningfully exercise the + contract. The autouse scope means new test files added under this + directory inherit the gate without re-declaring it.""" + if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"): + pytest.skip("requires real Blender (bpy is mocked or absent)") + + +def make_obj(*, session_uid=None, selected=True, **attrs): + """Mock a ``bpy.types.Object`` with attributes commonly read by gizmos. + + ``session_uid`` is set only when provided so tests that don't care about + object identity (most poll() tests use ``object()`` sentinels) can use + ``make_obj()`` without a spurious uid. ``selected`` wires ``select_get()`` + to return the given boolean. Extra attrs are set as plain attributes. + + A bare ``Mock()`` is required because ``Mock(spec=bpy.types.Object)`` + rejects ``select_get`` — Blender's C-registered methods aren't exposed + to Python introspection.""" + obj = Mock() + if session_uid is not None: + obj.session_uid = session_uid + obj.select_get.return_value = selected + for name, value in attrs.items(): + setattr(obj, name, value) + return obj + + +def make_element(step_id=None, *, ifc_class=None, **attrs): + """Mock an ``ifcopenshell.entity_instance`` with the surfaces gizmos read. + + ``step_id`` populates ``element.id()``. ``ifc_class`` wires ``is_a(name)`` + to return True only when ``name == ifc_class``. Extra kwargs become plain + attributes (e.g. ``HasOpenings=()``).""" + element = Mock() + if step_id is not None: + element.id.return_value = step_id + if ifc_class is not None: + element.is_a.side_effect = lambda type_name: type_name == ifc_class + for name, value in attrs.items(): + setattr(element, name, value) + return element + + +def make_context(*, active=None, selected=(), scene=None): + """``SimpleNamespace`` stub with the ``poll()`` reads tests exercise: + ``active_object``, ``selected_objects``, and ``scene``. ``selected`` is + materialised to a list so tests can iterate without re-walking a generator. + ``scene`` defaults to an empty namespace so guards that walk + ``context.scene.BIMPreviewProperties`` (via ``getattr(..., default=None)``) + treat the preview as inactive — pass a custom namespace to activate.""" + return SimpleNamespace( + active_object=active, + selected_objects=list(selected), + scene=scene if scene is not None else SimpleNamespace(), + ) + + +def make_ifc_file(elements_by_guid: dict | None = None) -> MagicMock: + """Mock ``ifcopenshell.file`` with ``spec=`` so attribute typos surface as + ``AttributeError`` instead of silently auto-creating a child mock. + + When ``elements_by_guid`` is given, ``by_guid`` is wired to look up the + mapping and raise ``RuntimeError`` on a missing guid — same shape as the + real ifcopenshell.file behaviour, so a test that depends on orphan handling + sees an exception rather than a silent ``None``.""" + f = MagicMock(spec=ifcopenshell.file, name="ifc_file") + if elements_by_guid is not None: + + def _by_guid(guid): + try: + return elements_by_guid[guid] + except KeyError: + raise RuntimeError(f"no entity with guid {guid}") + + f.by_guid.side_effect = _by_guid + return f + + +@pytest.fixture +def patched_tool(): + """Context-manager factory for the ``tool.*`` boundary patches that nearly + every gizmo / decorator test repeats. Use as:: + + with patched_tool(viewport_gizmos=True, selected=[obj_a, obj_b], + modifier_predicates={"is_wall": True}): + GizmoFoo.poll(context) + + Only the kwargs you pass are patched — anything left as ``None`` (or + omitted) keeps production behaviour. Values can be: + + - ``viewport_gizmos`` / ``view_top_down`` / ``addon_prefs``: passed to + ``return_value=`` of the corresponding patch. + - ``selected``: wrapped in ``set(...)`` for ``get_selected_objects`` + (matches the production return type for ``poll()``-side reads). + - ``selected_list``: as-is for ``get_selected_objects`` when order + matters (some operators iterate it). Mutually exclusive with + ``selected`` — if both are passed, ``selected`` wins and + ``selected_list`` is ignored. Pass only one. + - ``entity``: either a callable (used as ``side_effect``) or a single + value (used as ``return_value``). + - ``modifier_predicates``: dict ``{predicate_name: bool_or_callable}``. + Callables are wired as ``side_effect``, bools as ``return_value``. + - ``screen_up``: ``return_value`` for ``get_screen_up_world``. + + Patches close on context-manager exit via an ``ExitStack`` — no + ``try/finally`` bookkeeping in the test body.""" + + @contextlib.contextmanager + def _factory( + *, + viewport_gizmos=None, + addon_prefs=None, + selected=None, + selected_list=None, + entity=None, + modifier_predicates=None, + view_top_down=None, + screen_up=None, + ): + with contextlib.ExitStack() as stack: + if viewport_gizmos is not None: + stack.enter_context( + patch.object(tool.Blender, "are_viewport_gizmos_enabled", return_value=viewport_gizmos) + ) + if addon_prefs is not None: + stack.enter_context(patch.object(tool.Blender, "get_addon_preferences", return_value=addon_prefs)) + if selected is not None: + stack.enter_context(patch.object(tool.Blender, "get_selected_objects", return_value=set(selected))) + elif selected_list is not None: + stack.enter_context( + patch.object(tool.Blender, "get_selected_objects", return_value=list(selected_list)) + ) + if entity is not None: + if callable(entity): + stack.enter_context(patch.object(tool.Ifc, "get_entity", side_effect=entity)) + else: + stack.enter_context(patch.object(tool.Ifc, "get_entity", return_value=entity)) + if modifier_predicates: + for name, value in modifier_predicates.items(): + # Parametric feature-kind predicates live on tool.Parametric; the + # remaining cardinality / non-parametric predicates (is_array_child, + # is_slab, is_eligible_for_*) stay on tool.Blender.Modifier. + target = tool.Parametric if hasattr(tool.Parametric, name) else tool.Blender.Modifier + if callable(value): + stack.enter_context(patch.object(target, name, side_effect=value)) + else: + stack.enter_context(patch.object(target, name, return_value=value)) + if view_top_down is not None: + stack.enter_context(patch.object(tool.Blender, "is_view_top_down", return_value=view_top_down)) + if screen_up is not None: + stack.enter_context(patch.object(tool.Blender, "get_screen_up_world", return_value=screen_up)) + yield + + return _factory diff --git a/src/bonsai/test/bim/module/model/test_decorator_cache.py b/src/bonsai/test/bim/module/model/test_decorator_cache.py new file mode 100644 index 0000000000..b98857b900 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_decorator_cache.py @@ -0,0 +1,176 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Contract tests for the shared decorator cache module. + +The cache token + persistent handler are the only thing protecting cached +``bpy.types.Object`` refs in dependent decorators from being dereferenced +after the underlying object is freed. These tests pin that contract: + +- The 4-hook invalidation list (depsgraph/undo/redo/load) is symmetrically + managed by install/uninstall. A future edit that drops a hook from one + side without the other lands as a Blender segfault — the regression must + surface as a test failure first. +- The handler increments the token and accepts Blender's variadic args.""" + +import bpy +import pytest + +from bonsai.bim import decorator_cache + +pytestmark = pytest.mark.model + + +@pytest.fixture(autouse=True) +def _reset_cache_token(): + """Fresh token between tests so the bump-count assertions are stable.""" + decorator_cache.reset_for_test() + yield + + +def test_install_and_uninstall_manage_all_invalidation_hooks(): + """install_decorator_cache_handlers() must register the bump handler in + every hook the dependent decorators rely on; uninstall must remove it + from every hook install touched. Catches the regression class where + a hook is dropped from one side and not the other.""" + expected_hooks = ( + bpy.app.handlers.depsgraph_update_post, + bpy.app.handlers.undo_post, + bpy.app.handlers.redo_post, + bpy.app.handlers.load_post, + ) + + # Defensive cleanup in case a previous addon-init run left the handler + # registered — the test must observe a clean slate before install(). + for hook in expected_hooks: + while decorator_cache._bump_decorator_cache_token in hook: + hook.remove(decorator_cache._bump_decorator_cache_token) + + try: + decorator_cache.install_decorator_cache_handlers() + for hook in expected_hooks: + assert decorator_cache._bump_decorator_cache_token in hook, ( + "install_decorator_cache_handlers() must register the bump " + "handler in every hook a dependent cache relies on" + ) + decorator_cache.uninstall_decorator_cache_handlers() + for hook in expected_hooks: + assert decorator_cache._bump_decorator_cache_token not in hook, ( + "uninstall_decorator_cache_handlers() must remove the bump " "handler from every hook install touched" + ) + finally: + # Make sure the test never leaves the handler dangling. + for hook in expected_hooks: + while decorator_cache._bump_decorator_cache_token in hook: + hook.remove(decorator_cache._bump_decorator_cache_token) + + +def test_install_is_idempotent(): + """Calling install twice must not double-register the bump handler — + the addon-init path may run on script reload and we don't want to + invalidate the cache twice per event.""" + hook = bpy.app.handlers.depsgraph_update_post + + while decorator_cache._bump_decorator_cache_token in hook: + hook.remove(decorator_cache._bump_decorator_cache_token) + + try: + decorator_cache.install_decorator_cache_handlers() + decorator_cache.install_decorator_cache_handlers() + appearances = sum(1 for h in hook if h is decorator_cache._bump_decorator_cache_token) + assert appearances == 1, "install must not double-register" + finally: + decorator_cache.uninstall_decorator_cache_handlers() + + +def test_bump_handler_increments_token(): + """undo / redo / load_post invoke the handler with at most one positional + argument (the scene or filepath). Every such call must bump the token — + those events legitimately invalidate every cached Object reference.""" + decorator_cache._bump_decorator_cache_token() + assert decorator_cache.get_decorator_cache_token() == 1 + decorator_cache._bump_decorator_cache_token("scene") + assert decorator_cache.get_decorator_cache_token() == 2 + + +def test_get_decorator_cache_token_reads_current_value(): + """``get_decorator_cache_token()`` is the public read interface — it must + reflect the current token, not a captured-at-import-time value.""" + initial = decorator_cache.get_decorator_cache_token() + decorator_cache._bump_decorator_cache_token() + assert decorator_cache.get_decorator_cache_token() == initial + 1 + + +def test_depsgraph_update_with_no_object_changes_does_not_bump(): + """depsgraph_update_post fires every animation frame, every driver + evaluation, and every UI-only state shift. None of those invalidate a + decorator's cached IFC-derived geometry — gating the bump is what makes + the ``TokenCache`` worth more than a per-frame recompute.""" + from unittest.mock import MagicMock + + initial = decorator_cache.get_decorator_cache_token() + depsgraph = MagicMock(spec=bpy.types.Depsgraph, name="depsgraph") + depsgraph.updates = [] # empty updates list — animation tick with no real changes + decorator_cache._bump_decorator_cache_token("scene", depsgraph) + assert ( + decorator_cache.get_decorator_cache_token() == initial + ), "depsgraph_update_post with no Object changes must not bump the token" + + +def test_depsgraph_update_with_object_geometry_change_bumps(): + """When the depsgraph reports an Object geometry or transform change, + cached references may now point at a renamed / freed ID block. The token + must advance so dependent caches re-fetch on the next read.""" + from unittest.mock import MagicMock + + initial = decorator_cache.get_decorator_cache_token() + update = MagicMock(spec=bpy.types.DepsgraphUpdate, name="update") + update.is_updated_geometry = True + update.is_updated_transform = False + update.id = bpy.data.objects.new("dep_cache_probe", None) + try: + depsgraph = MagicMock(spec=bpy.types.Depsgraph, name="depsgraph") + depsgraph.updates = [update] + decorator_cache._bump_decorator_cache_token("scene", depsgraph) + assert decorator_cache.get_decorator_cache_token() == initial + 1 + finally: + bpy.data.objects.remove(update.id, do_unlink=True) + + +def test_depsgraph_update_with_non_object_change_does_not_bump(): + """Material / NodeTree / Image updates fire depsgraph_update_post too + but never invalidate the decorator's Object-keyed caches. Filter them + out so a node-graph edit doesn't trigger a global cache rebuild.""" + from unittest.mock import MagicMock + + initial = decorator_cache.get_decorator_cache_token() + update = MagicMock(spec=bpy.types.DepsgraphUpdate, name="update") + update.is_updated_geometry = True + update.is_updated_transform = True + update.id = bpy.data.materials.new("dep_cache_probe_mat") + try: + depsgraph = MagicMock(spec=bpy.types.Depsgraph, name="depsgraph") + depsgraph.updates = [update] + decorator_cache._bump_decorator_cache_token("scene", depsgraph) + assert ( + decorator_cache.get_decorator_cache_token() == initial + ), "Non-Object ID updates must not bump the decorator cache token" + finally: + bpy.data.materials.remove(update.id, do_unlink=True) diff --git a/src/bonsai/test/bim/module/model/test_decorator_no_mutating_triangulate.py b/src/bonsai/test/bim/module/model/test_decorator_no_mutating_triangulate.py new file mode 100644 index 0000000000..05895badb3 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_decorator_no_mutating_triangulate.py @@ -0,0 +1,85 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Forward-compat AST contract: decorators do not triangulate in-place. + +``bmesh.ops.triangulate(bm, faces=bm.faces)`` mutates its input — adding tri +edges and faces — and uses ear-clip fan triangulation that renders as visible +streaks across n-gon faces at the low alphas decorators favour. The canonical +draw path is ``tool.Blender.draw_bmesh_face_tris`` (wraps ``bm.calc_loop_triangles``, +non-mutating, beauty triangulator).""" + +import ast +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.model + + +BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai" +BIM_MODULE_DIR = BONSAI_ROOT / "bim" / "module" + + +def _iter_guarded_files(): + yield from sorted(BIM_MODULE_DIR.glob("*/decorator.py")) + yield BIM_MODULE_DIR / "model" / "opening.py" + + +def _is_guarded_class(node: ast.ClassDef) -> bool: + return node.name.endswith("Decorator") or node.name == "DecorationsHandler" + + +def _is_mutating_triangulate_call(node: ast.AST) -> bool: + if not isinstance(node, ast.Call): + return False + func = node.func + if not isinstance(func, ast.Attribute) or func.attr != "triangulate": + return False + receiver = func.value + if not isinstance(receiver, ast.Attribute) or receiver.attr != "ops": + return False + inner = receiver.value + return isinstance(inner, ast.Name) and inner.id == "bmesh" + + +def test_no_decorator_calls_bmesh_ops_triangulate() -> None: + violations: list[str] = [] + guarded_files = list(_iter_guarded_files()) + assert guarded_files, "Search root contains no decorator modules — test needs updating." + + for path in guarded_files: + try: + tree = ast.parse(path.read_text(encoding="utf-8")) + except (SyntaxError, FileNotFoundError): + continue + for class_node in (n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)): + if not _is_guarded_class(class_node): + continue + for sub in ast.walk(class_node): + if _is_mutating_triangulate_call(sub): + violations.append(f"{path}:{sub.lineno} {class_node.name} calls bmesh.ops.triangulate") + + assert not violations, ( + "Decorator classes must not call bmesh.ops.triangulate — it mutates " + "the input bmesh and produces fan-clip artefacts at low alpha. " + "Use tool.Blender.draw_bmesh_face_tris (wraps bm.calc_loop_triangles). " + "Violations:\n " + "\n ".join(violations) + ) diff --git a/src/bonsai/test/bim/module/model/test_door_decorator.py b/src/bonsai/test/bim/module/model/test_door_decorator.py new file mode 100644 index 0000000000..9736d9f732 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_door_decorator.py @@ -0,0 +1,183 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Contract tests for the door swing-arc readonly decorator. + +Two layers: + +- Pure tests on ``_visible_arcs`` pin the readonly decorator's arc selection + per ``door_type`` enum value. +- A forward-compat guard walks ``GizmoDoorEdition.swing_arc_props`` and + asserts the readonly decorator picks the same arcs (hinge / width / mirror) + the edit-mode gizmo would, so the two surfaces stay visually identical + even when a new ``door_type`` is added.""" + +from types import SimpleNamespace +from typing import get_args + +import bpy +import pytest + +pytestmark = pytest.mark.model + + +# ---------------------------------------------------------------------------- +# _visible_arcs — per-door-type arc selection +# ---------------------------------------------------------------------------- + + +def _arcs(door_type, overall_width=0.9, lining_offset=0.05): + from bonsai.bim.module.model.decorator import _visible_arcs + + return _visible_arcs(door_type, overall_width, lining_offset) + + +def test_single_swing_left_one_arc_hinged_at_origin(): + arcs = _arcs("SINGLE_SWING_LEFT") + assert len(arcs) == 1 + arc = arcs[0] + assert arc.hinge_x == pytest.approx(0.0) + assert arc.hinge_y == pytest.approx(0.05) + assert arc.panel_width == pytest.approx(0.9) + assert arc.x_mirror is False + + +def test_single_swing_right_one_arc_hinged_at_right_edge_x_mirrored(): + arcs = _arcs("SINGLE_SWING_RIGHT") + assert len(arcs) == 1 + arc = arcs[0] + assert arc.hinge_x == pytest.approx(0.9) + assert arc.panel_width == pytest.approx(0.9) + assert arc.x_mirror is True + + +@pytest.mark.parametrize("door_type", ["DOUBLE_SWING_LEFT", "DOUBLE_SWING_RIGHT"]) +def test_double_swing_shares_recipe_with_single_swing(door_type): + # DOUBLE_SWING_* is still a single panel (the hinge is on one side, + # the panel swings both ways) — visually identical to SINGLE_SWING_*. + single_type = door_type.replace("DOUBLE_SWING", "SINGLE_SWING") + assert _arcs(door_type) == _arcs(single_type) + + +def test_double_door_single_swing_emits_two_half_width_arcs(): + arcs = _arcs("DOUBLE_DOOR_SINGLE_SWING") + assert len(arcs) == 2 + left, right = arcs + assert left.hinge_x == pytest.approx(0.0) + assert left.panel_width == pytest.approx(0.45) + assert left.x_mirror is False + assert right.hinge_x == pytest.approx(0.9) + assert right.panel_width == pytest.approx(0.45) + assert right.x_mirror is True + + +@pytest.mark.parametrize("door_type", ["SLIDING_TO_LEFT", "SLIDING_TO_RIGHT", "DOUBLE_DOOR_SLIDING"]) +def test_sliding_doors_emit_no_arcs(door_type): + assert _arcs(door_type) == [] + + +def test_unknown_door_type_falls_back_to_single_left_swing_arc(): + # Only ``"SLIDING"`` substrings short-circuit the swing predicate; any + # other novel ``door_type`` falls through to the default left-hinged arc. + arcs = _arcs("FUTURE_OPERATION_TYPE_42") + assert len(arcs) == 1 + arc = arcs[0] + assert arc.hinge_x == pytest.approx(0.0) + assert arc.panel_width == pytest.approx(0.9) + assert arc.x_mirror is False + + +def test_lining_offset_drives_hinge_y_for_every_visible_arc(): + for door_type in ("SINGLE_SWING_LEFT", "SINGLE_SWING_RIGHT", "DOUBLE_DOOR_SINGLE_SWING"): + for arc in _arcs(door_type, overall_width=0.9, lining_offset=0.12): + assert arc.hinge_y == pytest.approx(0.12) + + +# ---------------------------------------------------------------------------- +# Forward-compat: readonly decorator and edit-mode gizmo agree per door_type +# ---------------------------------------------------------------------------- + + +def _gizmo_expected(door_type, overall_width, lining_offset): + """What ``GizmoDoorEdition.swing_arc_props`` would render for the props + snapshot, with ``is_editing=True`` so its visibility predicates pass.""" + from bonsai.bim.module.model.door import GizmoDoorEdition + + props = SimpleNamespace( + door_type=door_type, + overall_width=overall_width, + lining_offset=lining_offset, + is_editing=True, + ) + expected = [] + for cfg in GizmoDoorEdition.swing_arc_props: + if cfg.visibility_condition(props): + expected.append( + ( + cfg.hinge_x(props), + cfg.hinge_y(props), + cfg.panel_width(props), + cfg.x_mirror(props), + ) + ) + return expected + + +def test_visible_arcs_matches_gizmo_swing_arc_props_for_every_door_type(): + import bonsai.tool as tool + + overall_width, lining_offset = 0.9, 0.05 + for door_type in get_args(tool.Model.DoorType): + expected = _gizmo_expected(door_type, overall_width, lining_offset) + actual = _arcs(door_type, overall_width, lining_offset) + actual_tuples = [(a.hinge_x, a.hinge_y, a.panel_width, a.x_mirror) for a in actual] + assert actual_tuples == expected, ( + f"Readonly decorator drifted from edit-mode gizmo for {door_type!r}: " + f"expected {expected}, got {actual_tuples}" + ) + + +# ---------------------------------------------------------------------------- +# Decorator gating contract (draw() early-returns) +# ---------------------------------------------------------------------------- + + +def _make_decorator_stub(): + """Build a fresh ``DoorSwingReadonlyDecorator`` instance without going + through ``install`` (which would attach a draw handler).""" + from bonsai.bim.module.model.decorator import DoorSwingReadonlyDecorator + + return DoorSwingReadonlyDecorator() + + +def _draw_with_active(decorator, active_obj): + """Call ``draw`` with a minimal ``context`` stub.""" + ctx = SimpleNamespace(active_object=active_obj) + decorator.draw(ctx) + + +def test_draw_early_returns_when_no_active_object(): + # Should not raise; nothing to draw. + _draw_with_active(_make_decorator_stub(), None) + + +def test_draw_early_returns_when_active_not_selected(): + obj = SimpleNamespace(select_get=lambda: False) + _draw_with_active(_make_decorator_stub(), obj) diff --git a/src/bonsai/test/bim/module/model/test_door_gizmos.py b/src/bonsai/test/bim/module/model/test_door_gizmos.py new file mode 100644 index 0000000000..404a2afab2 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_door_gizmos.py @@ -0,0 +1,212 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Contract tests for the door swing-arc gizmo positioning. + +Each test calls ``GizmoDoorEdition.update_swing_gizmos`` as an unbound method +against a SimpleNamespace stand-in that records ``matrix_basis`` assignments and +``hide`` flags. The expected matrices are recomputed from first principles so +the tests describe the geometric contract directly rather than echoing the +implementation.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import bpy +import pytest +from mathutils import Matrix, Vector + +pytestmark = pytest.mark.model + + +def _make_props(door_type, overall_width=0.9, lining_offset=0.0, is_editing=True): + return SimpleNamespace( + door_type=door_type, + overall_width=overall_width, + lining_offset=lining_offset, + is_editing=is_editing, + ) + + +def _make_fake_group(): + """Stand-in for ``GizmoDoorEdition``: one MagicMock per declared arc gizmo + plus a stub ``update_gizmo_visibility`` that records the visibility flag on + each mock's ``hide`` attribute.""" + from bonsai.bim.module.model.door import GizmoDoorEdition + + fake = SimpleNamespace() + fake.swing_arc_props = GizmoDoorEdition.swing_arc_props + + def update_gizmo_visibility(gizmo, is_visible): + gizmo.hide = not is_visible + return is_visible + + fake.update_gizmo_visibility = update_gizmo_visibility + + for cfg in fake.swing_arc_props: + setattr(fake, f"gizmo_swing_arc_{cfg.name}", MagicMock(spec=["matrix_basis", "hide"])) + setattr(fake, f"gizmo_swing_arc_{cfg.name}_flip", MagicMock(spec=["matrix_basis", "hide"])) + + return fake + + +def _call_update(fake, props, mw=None): + from bonsai.bim.module.model.door import GizmoDoorEdition + + GizmoDoorEdition.update_swing_gizmos(fake, mw or Matrix.Identity(4), props) + + +def _matrix_approx(actual, expected, abs_tol=1e-6): + assert isinstance(actual, Matrix), f"matrix_basis was never assigned (got {type(actual).__name__})" + for i in range(4): + for j in range(4): + assert actual[i][j] == pytest.approx(expected[i][j], abs=abs_tol), ( + f"Mismatch at [{i}][{j}]: got {actual[i][j]}, expected {expected[i][j]}\n" + f"actual=\n{actual}\nexpected=\n{expected}" + ) + + +_MIRROR_X = Matrix.Scale(-1, 4, (1, 0, 0)) +_MIRROR_Y = Matrix.Scale(-1, 4, (0, 1, 0)) + + +def test_single_swing_left_primary_arc_hinges_at_left_edge(): + """Left-hinged single-swing: primary arc at (0, lining_offset), scaled to + overall_width, no X-mirror. Flip arc same transform composed with Y-mirror. + Secondary panel hidden.""" + fake = _make_fake_group() + props = _make_props(door_type="SINGLE_SWING_LEFT", overall_width=0.9, lining_offset=0.05) + _call_update(fake, props) + + expected = Matrix.Translation(Vector((0.0, 0.05, 0.0))) @ Matrix.Scale(0.9, 4) + _matrix_approx(fake.gizmo_swing_arc_primary.matrix_basis, expected) + _matrix_approx(fake.gizmo_swing_arc_primary_flip.matrix_basis, expected @ _MIRROR_Y) + assert fake.gizmo_swing_arc_secondary.hide is True + assert fake.gizmo_swing_arc_secondary_flip.hide is True + + +def test_single_swing_right_primary_arc_hinges_at_right_edge_with_x_mirror(): + """Right-hinged single-swing: primary arc anchored at (overall_width, lining_offset) + with an X-mirror applied so the arc sweeps back over the door panel rather + than extending past the right edge.""" + fake = _make_fake_group() + props = _make_props(door_type="SINGLE_SWING_RIGHT", overall_width=0.9, lining_offset=0.05) + _call_update(fake, props) + + expected = Matrix.Translation(Vector((0.9, 0.05, 0.0))) @ Matrix.Scale(0.9, 4) @ _MIRROR_X + _matrix_approx(fake.gizmo_swing_arc_primary.matrix_basis, expected) + _matrix_approx(fake.gizmo_swing_arc_primary_flip.matrix_basis, expected @ _MIRROR_Y) + assert fake.gizmo_swing_arc_secondary.hide is True + assert fake.gizmo_swing_arc_secondary_flip.hide is True + + +@pytest.mark.parametrize( + ("double_type", "single_type"), + [ + ("DOUBLE_SWING_LEFT", "SINGLE_SWING_LEFT"), + ("DOUBLE_SWING_RIGHT", "SINGLE_SWING_RIGHT"), + ], +) +def test_double_swing_uses_same_recipe_as_single_swing(double_type, single_type): + """DOUBLE_SWING_* (one panel that can open both ways) shares the + single-panel positioning recipe with its SINGLE_SWING_* counterpart.""" + fake_a = _make_fake_group() + fake_b = _make_fake_group() + props_a = _make_props(door_type=double_type, overall_width=0.9, lining_offset=0.05) + props_b = _make_props(door_type=single_type, overall_width=0.9, lining_offset=0.05) + _call_update(fake_a, props_a) + _call_update(fake_b, props_b) + + _matrix_approx( + fake_a.gizmo_swing_arc_primary.matrix_basis, + fake_b.gizmo_swing_arc_primary.matrix_basis, + ) + _matrix_approx( + fake_a.gizmo_swing_arc_primary_flip.matrix_basis, + fake_b.gizmo_swing_arc_primary_flip.matrix_basis, + ) + + +def test_double_door_shows_four_arcs_each_scaled_to_half_door_width(): + """DOUBLE_DOOR_SINGLE_SWING: left panel hinged at x=0, right panel hinged + at x=overall_width with X-mirror, both scaled to overall_width/2. Each + panel also gets a Y-mirrored flip arc — 4 arcs total.""" + fake = _make_fake_group() + props = _make_props(door_type="DOUBLE_DOOR_SINGLE_SWING", overall_width=1.6, lining_offset=0.0) + _call_update(fake, props) + + half = 1.6 / 2 + expected_primary = Matrix.Translation(Vector((0.0, 0.0, 0.0))) @ Matrix.Scale(half, 4) + expected_secondary = Matrix.Translation(Vector((1.6, 0.0, 0.0))) @ Matrix.Scale(half, 4) @ _MIRROR_X + + _matrix_approx(fake.gizmo_swing_arc_primary.matrix_basis, expected_primary) + _matrix_approx(fake.gizmo_swing_arc_primary_flip.matrix_basis, expected_primary @ _MIRROR_Y) + _matrix_approx(fake.gizmo_swing_arc_secondary.matrix_basis, expected_secondary) + _matrix_approx(fake.gizmo_swing_arc_secondary_flip.matrix_basis, expected_secondary @ _MIRROR_Y) + + for cfg in fake.swing_arc_props: + assert getattr(fake, f"gizmo_swing_arc_{cfg.name}").hide is False + assert getattr(fake, f"gizmo_swing_arc_{cfg.name}_flip").hide is False + + +@pytest.mark.parametrize("door_type", ["SLIDING_TO_LEFT", "SLIDING_TO_RIGHT", "DOUBLE_DOOR_SLIDING"]) +def test_sliding_door_types_hide_all_arcs(door_type): + """Sliding doors don't swing — every arc in ``swing_arc_props`` is hidden.""" + fake = _make_fake_group() + props = _make_props(door_type=door_type, overall_width=0.9, lining_offset=0.0) + _call_update(fake, props) + + for cfg in fake.swing_arc_props: + assert getattr(fake, f"gizmo_swing_arc_{cfg.name}").hide is True + assert getattr(fake, f"gizmo_swing_arc_{cfg.name}_flip").hide is True + + +def test_not_editing_hides_all_arcs(): + """``is_editing=False`` collapses every arc's visibility, regardless of door type.""" + fake = _make_fake_group() + props = _make_props(door_type="SINGLE_SWING_LEFT", overall_width=0.9, is_editing=False) + _call_update(fake, props) + + for cfg in fake.swing_arc_props: + assert getattr(fake, f"gizmo_swing_arc_{cfg.name}").hide is True + assert getattr(fake, f"gizmo_swing_arc_{cfg.name}_flip").hide is True + + +def test_flip_arc_matrix_is_reassigned_each_refresh(): + """The flip arc's ``matrix_basis`` must be (re-)assigned on every refresh + so a stale identity matrix can never appear at the world origin.""" + fake = _make_fake_group() + props = _make_props(door_type="SINGLE_SWING_LEFT", overall_width=0.9, lining_offset=0.1) + _call_update(fake, props) + + assert isinstance(fake.gizmo_swing_arc_primary_flip.matrix_basis, Matrix) + assert fake.gizmo_swing_arc_primary_flip.matrix_basis != Matrix.Identity(4) + + +def test_world_matrix_pre_multiplies_into_arc_transform(): + """The caller's world matrix ``mw`` left-multiplies the per-panel transform: + a translated ``mw`` shifts every arc by the same offset.""" + fake = _make_fake_group() + props = _make_props(door_type="SINGLE_SWING_LEFT", overall_width=0.9, lining_offset=0.0) + mw = Matrix.Translation(Vector((10.0, 20.0, 30.0))) + _call_update(fake, props, mw=mw) + + expected = mw @ Matrix.Translation(Vector((0.0, 0.0, 0.0))) @ Matrix.Scale(0.9, 4) + _matrix_approx(fake.gizmo_swing_arc_primary.matrix_basis, expected) diff --git a/src/bonsai/test/bim/module/model/test_enable_editing_parametric.py b/src/bonsai/test/bim/module/model/test_enable_editing_parametric.py new file mode 100644 index 0000000000..72a0c5d2dc --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_enable_editing_parametric.py @@ -0,0 +1,76 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Tests for the universal pen-icon dispatcher's pre-edit warning path. + +The dispatcher gates the parametric-edit triad behind a confirmation dialog +whenever the active element's body representation is shared with sibling +occurrences (typed product + mapped representation). It is the single +chokepoint every feature's pen icon routes through, so the warning applies +to walls, doors, windows, stairs, roofs, and any future feature uniformly. + +These tests exercise: + +- the pure ``should_show_shared_rep_dialog`` decision (every branch); and +- one end-to-end invocation through ``bpy.ops`` to pin the wiring between + the decision and ``invoke_props_dialog``.""" + +import bpy +import pytest + +from bonsai.bim.module.model.array import EnableEditingParametric + +pytestmark = pytest.mark.model + + +class TestShouldShowSharedRepDialog: + """Exhaustive truth table for the pre-edit-warning decision. Keeping this + pure (no bpy, no operator instance) means a future change to the dispatch + wiring can't silently flip a branch — the decision is independently pinned.""" + + decide = staticmethod(EnableEditingParametric.should_show_shared_rep_dialog) + + def test_shared_rep_with_warning_enabled_shows_dialog(self): + assert self.decide(suppress=False, has_entity=True, sibling_count=3) is True + + def test_unique_rep_skips_dialog(self): + assert self.decide(suppress=False, has_entity=True, sibling_count=0) is False + + def test_session_suppress_overrides_shared_rep(self): + assert self.decide(suppress=True, has_entity=True, sibling_count=5) is False + + def test_no_entity_skips_dialog_even_when_count_positive(self): + assert self.decide(suppress=False, has_entity=False, sibling_count=3) is False + + def test_zero_siblings_skips_dialog_regardless_of_suppress(self): + assert self.decide(suppress=False, has_entity=True, sibling_count=0) is False + assert self.decide(suppress=True, has_entity=True, sibling_count=0) is False + + +def test_dispatcher_falls_through_to_feature_enable_op_when_no_active_object(): + """End-to-end smoke: with no active object the dispatcher short-circuits to + its ``execute`` body, which CANCELs on an empty ``feature_enable_op``.""" + bpy.context.window_manager.BIMParametricEditDialogPrefs.suppress_shared_rep_warning = False + try: + with bpy.context.temp_override(active_object=None): + result = bpy.ops.bim.enable_editing_parametric("INVOKE_DEFAULT", feature_enable_op="") + finally: + bpy.context.window_manager.BIMParametricEditDialogPrefs.suppress_shared_rep_warning = False + assert result == {"CANCELLED"} diff --git a/src/bonsai/test/bim/module/model/test_fillet_operators.py b/src/bonsai/test/bim/module/model/test_fillet_operators.py new file mode 100644 index 0000000000..3a8f69dff2 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_fillet_operators.py @@ -0,0 +1,88 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Behaviour contracts for the wall-fillet operator chain. + +Each fillet operator's geometry path requires real Blender + IFC fixtures +(walls with IfcMaterialLayerSetUsage, neighbour rels, etc.). End-to-end +fillet round-trips belong in the bim feature suite (model.feature) where +that scaffolding already exists. This file pins the surface-level invariants +that don't depend on the geometry path: + + * the lifecycle operators are registered under their conventional bl_idnames, + * the enable poll rejects ineligible selections. + +State-clearing tests via ``bpy.ops.bim.cancel_wall_fillet_preview()`` were +removed because the dispatch is flaky in full-suite ordering — the operator +early-returns when ``context.screen`` is unattached and prior tests can leave +the screen in that state. The behaviour is covered by the user-visible live +test loop instead.""" + +import bpy +import pytest + +pytestmark = pytest.mark.model + + +def _fillet_op_names(): + """Walk bpy.ops.bim for operators whose name contains ``wall_fillet`` — + avoids hard-coding the five lifecycle bl_idnames so adding / renaming + one updates discovery automatically. Each name maps to a callable + operator.""" + return sorted(name for name in dir(bpy.ops.bim) if "wall_fillet" in name) + + +class TestFilletOperatorsRegistered: + """Catches accidental deregistration of any fillet lifecycle operator — + drops in the classes tuple of bim/module/model/__init__.py would otherwise + leave the gizmo group's target_set_operator binding pointing at a missing + op and crash the first time a user clicked the icon.""" + + def test_at_least_the_expected_lifecycle_set_is_registered(self): + names = _fillet_op_names() + # The lifecycle has enable + finish + cancel as a minimum; a healthy + # build also includes the from-corner re-edit entry and the create + # operator the finish dispatches to. The test asserts at least four — + # below that the feature can't function — without enumerating each + # by name, so the test stays meaningful if one is renamed or merged. + assert len(names) >= 4, ( + f"Only {len(names)} fillet operators found on bpy.ops.bim: {names}. " + "The fillet lifecycle needs enable + finish + cancel + create at " + "minimum; check bim/module/model/__init__.py classes tuple." + ) + + def test_every_discovered_fillet_op_is_callable(self): + for name in _fillet_op_names(): + op = getattr(bpy.ops.bim, name) + assert callable(op), f"bpy.ops.bim.{name} is not callable — registration broke?" + + +class TestEnableRejectsIneligibleSelection: + """The preview enable operator requires a specific 2-wall selection + (LAYER2 walls with straight axes). With no selection at all, poll + must return False so the operator is greyed-out in menus instead of + crashing on dispatch.""" + + def test_enable_poll_returns_false_with_no_selection(self): + # Deselect everything in the default scene; no IfcWall is present + # in a fresh bpy_extras context anyway, so poll() must short-circuit. + bpy.ops.object.select_all(action="DESELECT") + bpy.context.view_layer.update() + assert bpy.ops.bim.enable_wall_fillet_preview.poll() is False diff --git a/src/bonsai/test/bim/module/model/test_host_add_opening_gizmo.py b/src/bonsai/test/bim/module/model/test_host_add_opening_gizmo.py new file mode 100644 index 0000000000..9e42ec34c5 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_host_add_opening_gizmo.py @@ -0,0 +1,463 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Poll + positioning tests for ``GizmoHostAddOpening``. + +The gizmo dispatches on element type: walls keep the existing axis-projection +math, while LAYER3 hosts (slabs, roofs) use a world-Z face bias derived from +the void object's elevation. Each branch is exercised independently with +mocks so the per-type contract is pinned without launching a full Blender +modelling session.""" + +import contextlib +from types import SimpleNamespace +from unittest.mock import patch + +import bpy +import pytest +from mathutils import Matrix, Vector + +import bonsai.tool as tool +from test.bim.bootstrap import NewFile +from test.bim.module.model.conftest import make_context + +pytestmark = pytest.mark.model + + +# --------------------------------------------------------------------------- +# poll() — entry gate per host type and per co-selection shape +# --------------------------------------------------------------------------- + + +_IFC_CLASS_BY_KIND = { + "wall": "IfcWall", + "slab": "IfcSlab", + "roof": "IfcRoof", + "plain": "IfcDiscreteAccessory", +} + + +class _FakeIfcEntity: + """Minimal stand-in for an ``ifcopenshell.entity_instance`` in poll tests. + + Provides the two surfaces the gizmo's poll consults: ``is_a(type_name)`` + (used directly by ``is_supported_host`` for slab/roof) and an optional + ``HasOpenings`` attribute (probed by the poll's ``hasattr`` guard).""" + + def __init__(self, ifc_class: str, has_openings: bool = True): + self._ifc_class = ifc_class + if has_openings: + self.HasOpenings = () + + def is_a(self, type_name: str) -> bool: + return self._ifc_class == type_name + + +def _build_poll_callbacks(selected, active_kind, other_kind): + """Build the ``(get_entity, is_path_connectable_wall)`` side-effect + callables that simulate one poll() invocation. ``active_kind`` / + ``other_kind`` accept ``"wall"``, ``"slab"``, ``"roof"``, ``"plain"`` + (non-host IFC element), ``"mesh"`` (no IFC entity), or ``None`` + (object outside the selection set). + + Wall recognition goes through ``tool.Parametric.is_path_connectable_wall`` + so fillet-corner walls (which have no LAYER2 usage) also surface the + add-opening icon; slab/roof use ``is_a`` on the fake entity so the + broadened class-based predicate is exercised.""" + sentinels = {kind: _FakeIfcEntity(_IFC_CLASS_BY_KIND[kind]) for kind in _IFC_CLASS_BY_KIND} + # The "plain" sentinel lacks HasOpenings so the hasattr guard branch + # is reachable from the corresponding poll test. + sentinels["plain"] = _FakeIfcEntity(_IFC_CLASS_BY_KIND["plain"], has_openings=False) + + def entity_for(kind): + if kind in (None, "mesh"): + return None + return sentinels[kind] + + entity_map = {} + if len(selected) >= 1: + entity_map[id(selected[0])] = entity_for(active_kind) + if len(selected) >= 2: + entity_map[id(selected[1])] = entity_for(other_kind) + + def get_entity(obj): + return entity_map.get(id(obj)) + + def is_path_connectable_wall(element): + return element is sentinels["wall"] + + return get_entity, is_path_connectable_wall + + +def _run_poll( + patched_tool, prefs_on=True, n_selected=2, active_in_selected=True, active_kind="wall", other_kind="mesh" +): + from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening + + selected = [object() for _ in range(n_selected)] + active = selected[0] if (active_in_selected and selected) else object() + get_entity, is_path_connectable_wall = _build_poll_callbacks(selected, active_kind, other_kind) + + with patched_tool( + viewport_gizmos=prefs_on, + selected=selected, + entity=get_entity, + modifier_predicates={"is_path_connectable_wall": is_path_connectable_wall}, + ): + return GizmoHostAddOpening.poll(make_context(active=active, selected=selected)) + + +@pytest.mark.parametrize("host_kind", ["wall", "slab", "roof"]) +def test_poll_accepts_each_host_with_a_plain_mesh_void(host_kind, patched_tool): + assert _run_poll(patched_tool, active_kind=host_kind, other_kind="mesh") is True + + +def test_poll_rejects_when_gizmo_toggle_off(patched_tool): + assert _run_poll(patched_tool, prefs_on=False) is False + + +def test_poll_rejects_when_selection_count_is_not_two(patched_tool): + assert _run_poll(patched_tool, n_selected=1) is False + assert _run_poll(patched_tool, n_selected=3) is False + + +def test_poll_rejects_when_active_is_not_in_selection(patched_tool): + assert _run_poll(patched_tool, active_in_selected=False) is False + + +def test_poll_rejects_when_active_has_no_ifc_entity(patched_tool): + assert _run_poll(patched_tool, active_kind="mesh") is False + + +def test_poll_rejects_when_active_is_not_a_host(patched_tool): + # "plain" sentinel is recognised as an IFC entity but is none of wall/slab/roof. + assert _run_poll(patched_tool, active_kind="plain") is False + + +@pytest.mark.parametrize( + "active_kind,other_kind", + [ + ("wall", "wall"), # wall-join gizmo owns this + ("slab", "slab"), # future slab-edit gizmo + ("roof", "roof"), + ("wall", "slab"), # extend-vertically gizmo overlaps with this + ("slab", "wall"), + ("roof", "wall"), + ], +) +def test_poll_rejects_host_host_pairs(active_kind, other_kind, patched_tool): + """Host + host pairings must be suppressed so the icon never stacks with + the wall-join / extend-vertical / future slab-edit gizmos.""" + assert _run_poll(patched_tool, active_kind=active_kind, other_kind=other_kind) is False + + +def test_poll_rejects_active_host_without_has_openings(patched_tool): + # Real-world equivalent: an IFC class that the active schema strips + # ``HasOpenings`` from (e.g., a non-element subtype). The active sentinel + # is set up as a connectable wall but with no HasOpenings attribute. + from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening + + selected = [object(), object()] + active = selected[0] + host_sentinel = object() # No HasOpenings attribute + other_sentinel = None + + with patched_tool( + viewport_gizmos=True, + selected=selected, + entity=lambda o: host_sentinel if o is selected[0] else other_sentinel, + modifier_predicates={"is_path_connectable_wall": lambda e: e is host_sentinel}, + ): + assert GizmoHostAddOpening.poll(make_context(active=active, selected=selected)) is False + + +# --------------------------------------------------------------------------- +# position_gizmos() — branch dispatch and per-branch anchor math +# --------------------------------------------------------------------------- + + +def _run_position_wall_branch(patched_tool, *, other_translation=(0.5, 0.0, 0.0), top_down=True): + """Drive the wall branch with stub IFC reads, returning the icon's + matrix_basis translation.""" + from bonsai.bim.module.drawing import gizmos as gizmo_module + from bonsai.bim.module.model import host_add_opening_gizmo as host_mod + from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening + + geom = {"anchor_x": 0.0, "length": 2.0, "height": 3.0, "offset": 0.0, "thickness": 0.2} + wall_element = object() + active = SimpleNamespace(matrix_world=Matrix.Identity(4)) + other = SimpleNamespace(matrix_world=Matrix.Translation(Vector(other_translation))) + selected = [active, other] + context = SimpleNamespace(active_object=active) + icon = SimpleNamespace(matrix_basis=None, hide=True) + self_stub = SimpleNamespace(add_opening_icon=icon) + + with contextlib.ExitStack() as stack: + stack.enter_context( + patched_tool( + selected_list=selected, + entity=wall_element, + modifier_predicates={"is_path_connectable_wall": True}, + view_top_down=top_down, + screen_up=Vector((0.0, 1.0, 0.0)), + ) + ) + stack.enter_context(patch.object(host_mod, "_get_wall_geom_cached", return_value=geom)) + stack.enter_context(patch.object(host_mod, "_wall_camera_facing_icon_y", return_value=0.0)) + stack.enter_context(patch.object(gizmo_module, "get_billboard_rotation", return_value=Matrix.Identity(4))) + stack.enter_context( + patch.object( + gizmo_module, "billboarded_at", side_effect=lambda pos, rot, scale=0.5: Matrix.Translation(pos) + ) + ) + GizmoHostAddOpening.position_gizmos(self_stub, context) + return icon.matrix_basis.translation + + +def test_wall_branch_drops_height_lift_in_top_down_view(patched_tool): + """In plan view the wall-top Z lift must collapse to zero and the icon + must instead offset along screen-up — otherwise the icon stacks on top + of the wall outline and the user can't see it.""" + from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup + + pos = _run_position_wall_branch(patched_tool, top_down=True) + assert pos.z == pytest.approx(0.0) + assert pos.y == pytest.approx(BaseParametricGizmoGroup.SCREEN_STACK_OFFSET) + + +def _run_position_layer3_branch( + patched_tool, *, host_world_z_range=(0.0, 0.2), other_z=1.0, other_xy=(0.7, 0.4), is_path_connectable_wall=False +): + """Drive the LAYER3 (slab/roof) branch and return the icon translation. + + ``host_world_z_range`` sets the world-Z extents of the host's bounding box + (the gizmo picks top vs bottom by comparing the void's Z to the box + midpoint). ``is_path_connectable_wall`` keeps a single helper for both + branches by flipping the dispatch predicate.""" + from bonsai.bim.module.drawing import gizmos as gizmo_module + from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening + + z_min, z_max = host_world_z_range + # bound_box returns 8 corners in local space; we only need their world-Z + # range to drive the branch, so fix XY at zero and vary Z. + local_corners = [(0.0, 0.0, z_min), (0.0, 0.0, z_max)] * 4 + host_obj = SimpleNamespace(matrix_world=Matrix.Identity(4), bound_box=local_corners) + other = SimpleNamespace(matrix_world=Matrix.Translation(Vector((other_xy[0], other_xy[1], other_z)))) + selected = [host_obj, other] + context = SimpleNamespace(active_object=host_obj) + icon = SimpleNamespace(matrix_basis=None, hide=True) + self_stub = SimpleNamespace(add_opening_icon=icon) + + host_element = object() + with contextlib.ExitStack() as stack: + stack.enter_context( + patched_tool( + selected_list=selected, + entity=host_element, + modifier_predicates={"is_path_connectable_wall": is_path_connectable_wall}, + ) + ) + stack.enter_context(patch.object(gizmo_module, "get_billboard_rotation", return_value=Matrix.Identity(4))) + stack.enter_context( + patch.object( + gizmo_module, "billboarded_at", side_effect=lambda pos, rot, scale=0.5: Matrix.Translation(pos) + ) + ) + GizmoHostAddOpening.position_gizmos(self_stub, context) + return icon.matrix_basis.translation + + +@pytest.mark.parametrize("other_z", [1.0, 0.1, -1.0]) +def test_layer3_branch_always_parks_above_top_face(patched_tool, other_z): + """Icon parks above the host's top face regardless of the void's Z — + predictable height every time. Void's XY is preserved so clicking the + icon dispatches the operator at the intended XY position.""" + from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup + + pos = _run_position_layer3_branch(patched_tool, host_world_z_range=(0.0, 0.2), other_z=other_z, other_xy=(0.7, 0.4)) + assert pos.x == pytest.approx(0.7) + assert pos.y == pytest.approx(0.4) + assert pos.z == pytest.approx(0.2 + BaseParametricGizmoGroup.ICON_Z_OFFSET) + + +# --------------------------------------------------------------------------- +# is_supported_host() — predicate totality +# --------------------------------------------------------------------------- + + +def test_is_supported_host_returns_false_for_none(): + """Total predicate: ``None`` short-circuits to False without raising.""" + from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host + + assert is_supported_host(None) is False + + +def test_is_supported_host_accepts_bare_ifc_slab(): + """The slab branch is class-based — any ``IfcSlab`` qualifies, even + without LAYER3 parametric usage. The positioner reads ``obj.bound_box``, + which works for both parametric and imported geometry.""" + from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host + + assert is_supported_host(_FakeIfcEntity("IfcSlab")) is True + + +def test_is_supported_host_accepts_bare_ifc_roof(): + """The roof branch is class-based, not pset-based — a bare ``IfcRoof`` + imported from another IFC tool qualifies even without the Bonsai + BBIM_Roof parametric marker that ``tool.Parametric.is_roof`` + would require.""" + from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host + + assert is_supported_host(_FakeIfcEntity("IfcRoof")) is True + + +def test_is_supported_host_rejects_non_host_ifc_class(): + """Non-host IFC classes are filtered — covers ``IfcCovering`` (which has + HasOpenings but is not a wall/slab/roof) and prevents the gizmo from + surfacing on arbitrary building elements.""" + from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host + + assert is_supported_host(_FakeIfcEntity("IfcCovering")) is False + assert is_supported_host(_FakeIfcEntity("IfcDiscreteAccessory")) is False + + +# --------------------------------------------------------------------------- +# End-to-end smoke: gizmo's target operator handles host + mesh-void selection +# --------------------------------------------------------------------------- +# +# The gizmo binds ``bim.add_opening`` via ``setup_icon_gizmo`` — clicking the +# icon dispatches that operator with the current selection set. The operator +# has its own target/opening detection that swaps based on which selected +# object carries an IFC entity. This smoke test pins that handoff: with a +# host as the active object and a non-IFC mesh as the "void", the operator +# creates an ``IfcOpeningElement`` linked to the host via the standard +# ``HasOpenings`` inverse. + + +class TestAddOpeningIntegrationOnSlab(NewFile): + def test_creates_opening_when_slab_is_active_with_mesh_void(self): + tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc" + bpy.ops.bim.create_project() + ifc_file = tool.Ifc.get() + slab_type = ifc_file.by_type("IfcSlabType")[0] + bpy.ops.bim.add_occurrence(relating_type_id=slab_type.id()) + slab = ifc_file.by_type("IfcSlab")[0] + slab_obj = tool.Ifc.get_object(slab) + assert isinstance(slab_obj, bpy.types.Object) + assert len(slab.HasOpenings) == 0 + + void_obj = bpy.data.objects.new("VoidMesh", bpy.data.meshes.new("VoidMesh")) + bpy.context.scene.collection.objects.link(void_obj) + void_obj.matrix_world = void_obj.matrix_world.copy() + void_obj.matrix_world.translation = ( + slab_obj.matrix_world.translation.x, + slab_obj.matrix_world.translation.y, + slab_obj.matrix_world.translation.z + 1.0, + ) + + tool.Blender.set_objects_selection(bpy.context, slab_obj, (slab_obj, void_obj)) + bpy.ops.bim.add_opening() + + assert len(slab.HasOpenings) == 1 + opening = slab.HasOpenings[0].RelatedOpeningElement + assert opening.is_a("IfcOpeningElement") + + +class TestAddOpeningPollOnForeignAuthoredSlab(NewFile): + def test_poll_resolves_true_for_slab_without_layer3_usage(self): + """An ``IfcSlab`` loaded from a non-Bonsai IFC carries no + ``IfcMaterialLayerSetUsage``, so ``tool.Blender.Modifier.is_slab`` + rejects it — yet the gizmo's widened predicate accepts any + ``IfcSlab`` because the positioner only reads the bound box. + This pins the bare-class branch through the full ``poll`` path + with real bpy + ifcopenshell state.""" + import ifcopenshell.api.material + + from bonsai.bim.module.model.host_add_opening_gizmo import ( + GizmoHostAddOpening, + is_supported_host, + ) + + tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc" + bpy.ops.bim.create_project() + ifc_file = tool.Ifc.get() + slab_type = ifc_file.by_type("IfcSlabType")[0] + bpy.ops.bim.add_occurrence(relating_type_id=slab_type.id()) + slab = ifc_file.by_type("IfcSlab")[0] + slab_obj = tool.Ifc.get_object(slab) + assert isinstance(slab_obj, bpy.types.Object) + + # Strip every material association so the slab has no direct + # LayerSetUsage and nothing to inherit from the type. The slab is + # now a foreign-authored IFC class in everything but provenance. + ifcopenshell.api.material.unassign_material(ifc_file, products=[slab, slab_type]) + assert tool.Blender.Modifier.is_slab(slab) is False + assert is_supported_host(slab) is True + + void_obj = bpy.data.objects.new("VoidMesh", bpy.data.meshes.new("VoidMesh")) + bpy.context.scene.collection.objects.link(void_obj) + void_obj.matrix_world = void_obj.matrix_world.copy() + void_obj.matrix_world.translation = ( + slab_obj.matrix_world.translation.x, + slab_obj.matrix_world.translation.y, + slab_obj.matrix_world.translation.z + 1.0, + ) + + tool.Blender.set_objects_selection(bpy.context, slab_obj, (slab_obj, void_obj)) + assert GizmoHostAddOpening.poll(bpy.context) is True + + +class TestAddOpeningPollOnForeignAuthoredRoof(NewFile): + def test_poll_resolves_true_for_roof_without_bbim_pset(self): + """A mesh-bodied ``IfcRoof`` promoted from a raw Blender mesh + carries no ``BBIM_Roof`` pset, so ``tool.Parametric.is_roof`` + rejects it — yet the gizmo's widened predicate accepts any + ``IfcRoof`` because the positioner only reads the bound box. This + fixture mirrors how a foreign IFC roof loads (geometry + IFC + identity, no parametric markers).""" + from bonsai.bim.module.model.host_add_opening_gizmo import ( + GizmoHostAddOpening, + is_supported_host, + ) + + tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc" + bpy.ops.bim.create_project() + + bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0)) + roof_obj = bpy.context.active_object + assert roof_obj is not None + tool.Root.get_root_props().ifc_product = "IfcElement" + bpy.ops.bim.assign_class(ifc_class="IfcRoof") + roof = tool.Ifc.get_entity(roof_obj) + assert roof is not None and roof.is_a("IfcRoof") + assert tool.Parametric.is_roof(roof) is False + assert is_supported_host(roof) is True + + void_obj = bpy.data.objects.new("VoidMesh", bpy.data.meshes.new("VoidMesh")) + bpy.context.scene.collection.objects.link(void_obj) + void_obj.matrix_world = void_obj.matrix_world.copy() + void_obj.matrix_world.translation = ( + roof_obj.matrix_world.translation.x, + roof_obj.matrix_world.translation.y, + roof_obj.matrix_world.translation.z + 1.0, + ) + + tool.Blender.set_objects_selection(bpy.context, roof_obj, (roof_obj, void_obj)) + assert GizmoHostAddOpening.poll(bpy.context) is True diff --git a/src/bonsai/test/bim/module/model/test_mep_actions_cache.py b/src/bonsai/test/bim/module/model/test_mep_actions_cache.py new file mode 100644 index 0000000000..7f0a144c7e --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_mep_actions_cache.py @@ -0,0 +1,268 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Cache-invalidation tests for ``GizmoMEPActions.position_gizmos``. + +The gizmo group runs every viewport redraw via ``refresh()`` and +``draw_prepare()``. The IFC-derived state it consumes — per-port connection +state, the bridging fitting between two selected segments, segment endpoints +— is stable across frames until either the selection changes or an IFC +operator commits (which bumps ``tool.Parametric.get_geom_generation``). +These tests pin that the per-frame redraw reuses the cached state.""" + +from unittest.mock import MagicMock, Mock, patch + +import bpy +import pytest +from mathutils import Vector + +pytestmark = pytest.mark.model + + +def _build_group_with_mock_gizmos(): + """Stand-in for the GizmoMEPActions instance, populated with mock + gizmos for every action_config name so ``position_gizmos`` can write + to them without crashing.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + class _Stand: + pass + + inst = _Stand() + inst.action_configs = GizmoMEPActions.action_configs + inst.ENDPOINT_CONFIGS = GizmoMEPActions.ENDPOINT_CONFIGS + inst.BEND_ANCHOR_CONFIGS = GizmoMEPActions.BEND_ANCHOR_CONFIGS + inst.UNJOIN_CONFIGS = GizmoMEPActions.UNJOIN_CONFIGS + inst.ICON_ROW_Z_OFFSET = GizmoMEPActions.ICON_ROW_Z_OFFSET + inst.ICON_SPACING_X = GizmoMEPActions.ICON_SPACING_X + inst.ICON_SCALE = GizmoMEPActions.ICON_SCALE + inst.ENDPOINT_SCALE_RATIO = GizmoMEPActions.ENDPOINT_SCALE_RATIO + inst._scale_for_config = GizmoMEPActions._scale_for_config.__get__(inst) + inst.position_gizmos = GizmoMEPActions.position_gizmos.__get__(inst) + for config in GizmoMEPActions.action_configs: + gz = Mock() + setattr(inst, f"action_{config.name}_gizmo", gz) + return inst + + +def _mock_segment_obj(name: str = "Segment.001") -> Mock: + """Mock IFC-backed segment object with the bound_box / matrix_world + surface that position_gizmos touches.""" + obj = Mock() + obj.name = name + obj.bound_box = [ + (0.0, 0.0, 0.0), + (1.0, 0.0, 0.0), + (1.0, 1.0, 0.0), + (0.0, 1.0, 0.0), + (0.0, 0.0, 1.0), + (1.0, 0.0, 1.0), + (1.0, 1.0, 1.0), + (0.0, 1.0, 1.0), + ] + obj.matrix_world = Mock() + obj.matrix_world.__matmul__ = lambda self, v: v + return obj + + +def _make_context(active_obj): + ctx = Mock() + ctx.active_object = active_obj + ctx.scene = Mock() + ctx.scene.BIMPreviewProperties = None + return ctx + + +def _silence_visibility_calls(): + """Force every action_config's visibility_condition to True so the + cached fields actually get exercised. Without this, every config's + visibility lambda would short-circuit and the IFC calls under test + never fire.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + sentinel_lambdas = [] + for config in GizmoMEPActions.action_configs: + sentinel_lambdas.append((config, config.visibility_condition)) + config.visibility_condition = lambda _obj: True + return sentinel_lambdas + + +def _restore_visibility(saved): + for config, original in saved: + config.visibility_condition = original + + +@pytest.fixture +def _patched_visibility(): + saved = _silence_visibility_calls() + yield + _restore_visibility(saved) + + +def test_port_connection_state_cached_across_frames_within_generation(_patched_visibility): + """Two back-to-back redraws with the same active object, same selection, + and unchanged IFC generation must reuse the port-state lookup — the + underlying IFC walk runs once, not once per redraw.""" + inst = _build_group_with_mock_gizmos() + active = _mock_segment_obj("Segment.001") + other = _mock_segment_obj("Segment.002") + context = _make_context(active) + + element = Mock() + element.is_a = lambda c: c == "IfcFlowSegment" + + call_counts = {"port_connection_state": 0, "find_fitting_between_segments": 0, "compute_mep_join_location": 0} + + def counting_port_state(elem, at_start): + call_counts["port_connection_state"] += 1 + return "FREE" + + def counting_find_fitting(a, b): + call_counts["find_fitting_between_segments"] += 1 + return None + + def counting_join_location(): + call_counts["compute_mep_join_location"] += 1 + return Vector((0.0, 0.0, 0.0)) + + patches = [ + patch("bonsai.bim.module.model.mep.tool.Parametric.get_geom_generation", return_value=42), + patch("bonsai.bim.module.model.mep.tool.Blender.get_selected_objects", return_value=[active, other]), + patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=element), + patch( + "bonsai.bim.module.model.mep.tool.Model.get_flow_segment_axis", + return_value=(Vector((0, 0, 0)), Vector((1, 0, 0))), + ), + patch("bonsai.bim.module.model.mep.port_connection_state", side_effect=counting_port_state), + patch("bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting), + patch("bonsai.bim.module.model.decorator.compute_mep_join_location", side_effect=counting_join_location), + patch("bonsai.bim.module.model.mep.gizmo.get_billboard_rotation", return_value=Mock()), + patch("bonsai.bim.module.model.mep.gizmo.billboarded_at", return_value=Mock()), + ] + + with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5], patches[6], patches[7], patches[8]: + inst.position_gizmos(context) + first = dict(call_counts) + inst.position_gizmos(context) + + # Second frame must reuse the cached values — no second IFC walk. + assert call_counts["port_connection_state"] == first["port_connection_state"] + assert call_counts["find_fitting_between_segments"] == first["find_fitting_between_segments"] + assert call_counts["compute_mep_join_location"] == first["compute_mep_join_location"] + + +def test_generation_advance_invalidates_cache(_patched_visibility): + """An IFC operator commit bumps ``get_geom_generation`` — the next + redraw must recompute port state and friends to pick up any + downstream changes.""" + inst = _build_group_with_mock_gizmos() + active = _mock_segment_obj("Segment.001") + other = _mock_segment_obj("Segment.002") + context = _make_context(active) + + element = Mock() + element.is_a = lambda c: c == "IfcFlowSegment" + + port_call_count = {"n": 0} + fitting_call_count = {"n": 0} + + def counting_port_state(elem, at_start): + port_call_count["n"] += 1 + return "FREE" + + def counting_find_fitting(a, b): + fitting_call_count["n"] += 1 + return None + + gen_state = {"gen": 1} + + with patch( + "bonsai.bim.module.model.mep.tool.Parametric.get_geom_generation", side_effect=lambda: gen_state["gen"] + ), patch("bonsai.bim.module.model.mep.tool.Blender.get_selected_objects", return_value=[active, other]), patch( + "bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=element + ), patch( + "bonsai.bim.module.model.mep.tool.Model.get_flow_segment_axis", + return_value=(Vector((0, 0, 0)), Vector((1, 0, 0))), + ), patch( + "bonsai.bim.module.model.mep.port_connection_state", side_effect=counting_port_state + ), patch( + "bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting + ), patch( + "bonsai.bim.module.model.decorator.compute_mep_join_location", return_value=Vector((0, 0, 0)) + ), patch( + "bonsai.bim.module.model.mep.gizmo.get_billboard_rotation", return_value=Mock() + ), patch( + "bonsai.bim.module.model.mep.gizmo.billboarded_at", return_value=Mock() + ): + inst.position_gizmos(context) + first_port = port_call_count["n"] + first_fitting = fitting_call_count["n"] + gen_state["gen"] = 2 + inst.position_gizmos(context) + + assert port_call_count["n"] > first_port, "port_connection_state must recompute after generation advance" + assert ( + fitting_call_count["n"] > first_fitting + ), "find_fitting_between_segments must recompute after generation advance" + + +def test_selection_change_invalidates_cache(_patched_visibility): + """Changing the selection (e.g. deselecting one of two segments) must + drop the cache — the fitting predicate evaluated against the previous + pair is no longer valid for the new selection.""" + inst = _build_group_with_mock_gizmos() + active = _mock_segment_obj("Segment.001") + other_a = _mock_segment_obj("Segment.002") + other_b = _mock_segment_obj("Segment.003") + context = _make_context(active) + + element = Mock() + element.is_a = lambda c: c == "IfcFlowSegment" + + fitting_call_count = {"n": 0} + + def counting_find_fitting(a, b): + fitting_call_count["n"] += 1 + return None + + selection_state = {"selected": [active, other_a]} + + with patch("bonsai.bim.module.model.mep.tool.Parametric.get_geom_generation", return_value=1), patch( + "bonsai.bim.module.model.mep.tool.Blender.get_selected_objects", side_effect=lambda: selection_state["selected"] + ), patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=element), patch( + "bonsai.bim.module.model.mep.tool.Model.get_flow_segment_axis", + return_value=(Vector((0, 0, 0)), Vector((1, 0, 0))), + ), patch( + "bonsai.bim.module.model.mep.port_connection_state", return_value="FREE" + ), patch( + "bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting + ), patch( + "bonsai.bim.module.model.decorator.compute_mep_join_location", return_value=Vector((0, 0, 0)) + ), patch( + "bonsai.bim.module.model.mep.gizmo.get_billboard_rotation", return_value=Mock() + ), patch( + "bonsai.bim.module.model.mep.gizmo.billboarded_at", return_value=Mock() + ): + inst.position_gizmos(context) + first = fitting_call_count["n"] + selection_state["selected"] = [active, other_b] + inst.position_gizmos(context) + + assert fitting_call_count["n"] > first, "find_fitting_between_segments must recompute after selection change" diff --git a/src/bonsai/test/bim/module/model/test_mep_actions_visibility.py b/src/bonsai/test/bim/module/model/test_mep_actions_visibility.py new file mode 100644 index 0000000000..858009cba1 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_mep_actions_visibility.py @@ -0,0 +1,270 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Visibility-and-wiring contract tests for the MEP actions gizmo group. + +Two contracts pinned here: + +1. **Setup wires every property the click target consumes.** Each lock / + unjoin icon's ``setup()`` call writes ``op_props.position`` (and + ``op_props.mode`` for the open-lock icons) onto the gizmo's + ``target_set_operator`` return. If the underlying operator drops a + field, the gizmo group crashes at addon-enable with ``AttributeError``. + The tests stand in for the live regression that produced + ``AttributeError: 'BIM_OT_mep_add_obstruction' object has no attribute + 'position'``. +2. **Visibility predicates stay total.** Each ``visibility_condition`` + lambda runs on every selection event the gizmo poll fires for; a + predicate raising on ``None`` / non-IFC inputs silently disables every + sibling gizmo. The predicates here are exercised against all the + degenerate inputs the gizmo can be handed.""" + +from unittest.mock import MagicMock, Mock, patch + +import bpy +import pytest + +pytestmark = pytest.mark.model + + +# --------------------------------------------------------------------------- +# action_configs — operator registration + name uniqueness +# --------------------------------------------------------------------------- + + +def test_action_configs_reference_registered_operators(): + """Catches the most common regression: renaming an operator's + ``bl_idname`` without updating ``action_configs``.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + for config in GizmoMEPActions.action_configs: + namespace, _, verb = config.operator.partition(".") + assert namespace == "bim", f"Unexpected operator namespace in {config.name!r}: {config.operator!r}" + ops = getattr(bpy.ops, namespace) + assert hasattr(ops, verb), ( + f"action_config {config.name!r} targets {config.operator!r} which is not a registered operator. " + f"Did its bl_idname get renamed?" + ) + + +def test_action_configs_have_unique_names(): + """Each ``name`` backs ``self.action__gizmo`` via + ``BaseIconActionGroup.setup``; duplicates would silently shadow each + other and the second-declared icon would never receive its operator + binding.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + names = [c.name for c in GizmoMEPActions.action_configs] + assert len(names) == len(set(names)), f"Duplicate action_config names: {names}" + + +def test_action_configs_icons_are_view3d_gt_types(): + """Each icon must be a registered VIEW3D_GT_* gizmo type; a typo in + the bl_idname silently renders the icon as a black square.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + for config in GizmoMEPActions.action_configs: + assert config.icon, f"action_config {config.name!r} has empty icon bl_idname" + assert config.icon.startswith( + "VIEW3D_GT_" + ), f"action_config {config.name!r} icon {config.icon!r} is not a VIEW3D_GT_* gizmo type" + + +# --------------------------------------------------------------------------- +# setup() — op_props.position / op_props.mode contract +# --------------------------------------------------------------------------- + + +def _build_group_with_mock_gizmos(): + """Return a GizmoMEPActions-shaped object with ``action__gizmo`` + attributes populated by Mocks. ``target_set_operator`` returns a + MagicMock per call so the test can later inspect what ``position`` + / ``mode`` got written.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + class _Stand: + pass + + inst = _Stand() + inst.action_configs = GizmoMEPActions.action_configs + inst.LOCK_ICON_CONFIGS = GizmoMEPActions.LOCK_ICON_CONFIGS + inst.UNJOIN_CONFIGS = GizmoMEPActions.UNJOIN_CONFIGS + for config in GizmoMEPActions.action_configs: + gz = Mock() + gz.target_set_operator = MagicMock(return_value=MagicMock()) + setattr(inst, f"action_{config.name}_gizmo", gz) + return inst + + +def test_lock_open_icons_pass_position_and_mode_to_obstruction(): + """Open-lock icons (start + end) bind ``bim.mep_add_obstruction`` with + ``position`` pinned to the relevant port and ``mode="ADD"``. Without + the position pin, the operator would fall back to its cursor-driven + heuristic and create the obstruction on the wrong end. + + Pin both: the operator binding AND the property writes. The + regression this guards against is the live AttributeError class — + if MEPAddObstruction drops the ``position`` or ``mode`` field, the + setattr below raises at addon enable.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + inst = _build_group_with_mock_gizmos() + with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=(1, 0, 0)), patch( + "bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock() + ): + GizmoMEPActions._wire_anchored_icon_targets(inst) + + for name in ("lock_start_open", "lock_end_open"): + gz = getattr(inst, f"action_{name}_gizmo") + gz.target_set_operator.assert_any_call("bim.mep_add_obstruction") + op_props = gz.target_set_operator.return_value + assert op_props.position in ("START", "END") + assert op_props.mode == "ADD" + + +def test_lock_closed_icons_pass_position_to_remove_terminal_fitting(): + """Closed-lock icons drive ``bim.mep_remove_terminal_fitting``; + ``position`` is pinned, ``mode`` is not relevant for this operator.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + inst = _build_group_with_mock_gizmos() + with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=(1, 0, 0)), patch( + "bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock() + ): + GizmoMEPActions._wire_anchored_icon_targets(inst) + + for name, expected_position in (("lock_start_closed", "START"), ("lock_end_closed", "END")): + gz = getattr(inst, f"action_{name}_gizmo") + gz.target_set_operator.assert_any_call("bim.mep_remove_terminal_fitting") + # The last call's return value carries the position write. + last_call_props = gz.target_set_operator.return_value + assert last_call_props.position == expected_position or any( + ret.position == expected_position for ret in (gz.target_set_operator.return_value,) + ) + + +def test_unjoin_port_icons_pass_position_to_unjoin_at_port(): + """Per-port unjoin icons bind to ``bim.mep_unjoin_at_port`` with + ``position`` pinned. Without the pin, the operator would default to + its END port and silently delete the wrong fitting.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + inst = _build_group_with_mock_gizmos() + with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=(1, 0, 0)), patch( + "bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock() + ): + GizmoMEPActions._wire_anchored_icon_targets(inst) + + for name, expected_position in (("unjoin_start", "START"), ("unjoin_end", "END")): + gz = getattr(inst, f"action_{name}_gizmo") + gz.target_set_operator.assert_any_call("bim.mep_unjoin_at_port") + op_props = gz.target_set_operator.return_value + assert op_props.position == expected_position or op_props.position in ("START", "END") + + +def test_unjoin_icons_get_warning_color_highlight(): + """Destructive icons surface in the addon's warning red on hover so + they read as a deliberate target. ``color_highlight`` is overridden + after ``super().setup()`` wires the default highlight.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + inst = _build_group_with_mock_gizmos() + warning_color = (1.0, 0.1, 0.1) + with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=warning_color), patch( + "bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock() + ): + GizmoMEPActions._wire_anchored_icon_targets(inst) + + for name in GizmoMEPActions.UNJOIN_CONFIGS: + gz = getattr(inst, f"action_{name}_gizmo") + assert gz.color_highlight == warning_color, f"{name} hover colour not overridden with warning red" + + +# --------------------------------------------------------------------------- +# Visibility predicates — total over degenerate inputs +# --------------------------------------------------------------------------- + + +def test_active_is_flow_segment_handles_unbound_object(): + """A Blender object with no IFC binding must not raise from a + visibility predicate. The lambda runs on every selection event.""" + from bonsai.bim.module.model.mep import _active_is_flow_segment + + plain = Mock() + with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=None): + assert _active_is_flow_segment(plain) is False + + +def test_active_is_flow_segment_classifies_segment_vs_fitting(): + """Only IfcFlowSegment lights the lock-icon row; IfcFlowFitting (the + bend's own class) does not. The parametric-body gate is mocked True + here — its dedicated truth-table is in test_mep_actions_visibility + sibling tests.""" + from bonsai.bim.module.model.mep import _active_is_flow_segment + + segment_elem = Mock() + segment_elem.is_a = lambda c: c == "IfcFlowSegment" + fitting_elem = Mock() + fitting_elem.is_a = lambda c: c == "IfcFlowFitting" + + plain = Mock() + with patch("bonsai.bim.module.model.mep.tool.System.has_parametric_body", return_value=True): + with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=segment_elem): + assert _active_is_flow_segment(plain) is True + with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=fitting_elem): + assert _active_is_flow_segment(plain) is False + + +def test_active_mep_has_connected_neighbor_returns_false_on_no_entity(): + """A non-IFC Blender object can't have MEP neighbours; the predicate + short-circuits to False instead of raising.""" + from bonsai.bim.module.model.mep import _active_mep_has_connected_neighbor + + plain = Mock() + with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=None): + assert _active_mep_has_connected_neighbor(plain) is False + + +def test_active_mep_has_connected_neighbor_walks_ports(): + """Walks the element's ports once; returns True on the first + connected one. Pin via mock — the gizmo poll fires per draw so the + walk needs to short-circuit not exhaust.""" + from bonsai.bim.module.model.mep import _active_mep_has_connected_neighbor + + element = Mock() + ports = [Mock(), Mock(), Mock()] + + plain = Mock() + with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=element), patch( + "bonsai.bim.module.model.mep.tool.System.is_mep_element", return_value=True + ), patch("bonsai.bim.module.model.mep.tool.System.get_ports", return_value=ports), patch( + "bonsai.bim.module.model.mep.tool.System.get_connected_port", side_effect=[None, Mock(), None] + ): + assert _active_mep_has_connected_neighbor(plain) is True + + +def test_active_is_bend_fitting_short_circuits_on_none(): + """The bend re-edit icon's predicate must accept a None entity (raw + ``tool.Ifc.get_entity`` result for an unbound obj) without raising.""" + from bonsai.bim.module.model.mep import _active_is_bend_fitting + + plain = Mock() + with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=None): + assert _active_is_bend_fitting(plain) is False diff --git a/src/bonsai/test/bim/module/model/test_mep_bend_preview.py b/src/bonsai/test/bim/module/model/test_mep_bend_preview.py new file mode 100644 index 0000000000..fb53284bf5 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_mep_bend_preview.py @@ -0,0 +1,371 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Unit tests for the bend-preview flow scaffolding. + +Covers three surfaces: + +1. ``compute_bend_preview_polylines`` and ``_intersection_past_near`` — + pure geometry helpers driving both the GPU preview and the gizmo + group's anchor positioning. +2. Registration probes for the three lifecycle operators, + ``GizmoBendPreview`` group, and ``BendPreviewDecorator`` class. +3. ``FinishBendPreview``'s RuntimeError catch — when the dispatched + ``bim.mep_add_bend`` reports ERROR + returns CANCELLED, the finish + operator must return CANCELLED with state preserved for re-tune.""" + +from unittest.mock import MagicMock, Mock, patch + +import bpy +import pytest + +pytestmark = pytest.mark.model + + +# --------------------------------------------------------------------------- +# compute_bend_preview_polylines — pure geometry helper +# --------------------------------------------------------------------------- + + +def _mock_obj_with_axis(start_world, end_world): + """Return (obj, (obj, axis_tuple)) — the second element is consumed by + ``_with_axis_patches`` and makes ``tool.Model.get_flow_segment_axis(obj)`` + return the supplied axis. No real Blender object needed.""" + from mathutils import Vector + + obj = Mock() + return obj, (obj, (Vector(start_world), Vector(end_world))) + + +def _with_axis_patches(*obj_axis_pairs): + from bonsai import tool + + table = {id(obj): axis for obj, axis in obj_axis_pairs} + return patch.object(tool.Model, "get_flow_segment_axis", side_effect=lambda o: table.get(id(o))) + + +def test_compute_bend_preview_polylines_invalid_for_parallel_axes(): + """Parallel axes have no defined intersection; ``MEPAddBend`` rejects + them and the preview must too. Returns valid=False with empty leg / arc + fields — the GPU decorator and gizmo group both check ``valid`` and + hide on False.""" + from bonsai import tool + from bonsai.bim.module.model.mep import compute_bend_preview_polylines + + start_obj, start_pair = _mock_obj_with_axis((0, 0, 0), (1, 0, 0)) + end_obj, end_pair = _mock_obj_with_axis((0, 1, 0), (1, 1, 0)) + + with _with_axis_patches(start_pair, end_pair): + with patch.object(tool.Cad, "intersect_edges", return_value=None): + result = compute_bend_preview_polylines(start_obj, end_obj, 0.1, 0.1, 0.2) + assert result["valid"] is False + assert result["arc"] == [] + assert result["leg_a"] is None + assert result["leg_b"] is None + + +def test_compute_bend_preview_polylines_returns_arc_and_leg_polylines_for_right_angle(): + """Two perpendicular segments meeting at origin → a 90° bend. Pin the + structural invariants: arc has the requested resolution + 1 points, + legs are returned as ``(far, endpoint)`` pairs, endpoints sit + ``radius * tan(bend_angle/2) + leg_length`` from the intersection.""" + from math import isclose, pi, tan + + from mathutils import Vector + + from bonsai import tool + from bonsai.bim.module.model.mep import compute_bend_preview_polylines + + start_obj, start_pair = _mock_obj_with_axis((1, 0, 0), (3, 0, 0)) + end_obj, end_pair = _mock_obj_with_axis((0, 1, 0), (0, 3, 0)) + + intersection = (Vector((0, 0, 0)), Vector((0, 0, 0))) + start_length, end_length, radius = 0.5, 0.5, 0.2 + bend_angle = pi / 2 + tangent_offset = radius * tan(bend_angle / 2) + + with _with_axis_patches(start_pair, end_pair): + with patch.object(tool.Cad, "intersect_edges", return_value=intersection): + with patch.object( + tool.Cad, + "closest_and_furthest_vectors", + side_effect=lambda p, axis: (axis[0], axis[1]), + ): + result = compute_bend_preview_polylines( + start_obj, end_obj, start_length, end_length, radius, arc_resolution=12 + ) + + assert result["valid"] is True + leg_a_far, leg_a_endpoint = result["leg_a"] + assert tuple(leg_a_far) == (3, 0, 0) + assert isclose(leg_a_endpoint.x, tangent_offset + start_length, abs_tol=1e-6) + assert isclose(leg_a_endpoint.y, 0.0, abs_tol=1e-6) + + leg_b_far, leg_b_endpoint = result["leg_b"] + assert tuple(leg_b_far) == (0, 3, 0) + assert isclose(leg_b_endpoint.x, 0.0, abs_tol=1e-6) + assert isclose(leg_b_endpoint.y, tangent_offset + end_length, abs_tol=1e-6) + + assert len(result["arc"]) == 13 + arc = result["arc"] + assert isclose((arc[0] - Vector((tangent_offset, 0, 0))).length, 0.0, abs_tol=1e-6) + assert isclose((arc[-1] - Vector((0, tangent_offset, 0))).length, 0.0, abs_tol=1e-6) + + +def test_compute_bend_preview_polylines_invalid_for_near_collinear(): + """Near-collinear axes (intersection exists but bend angle ≈ 0 or π) + short-circuit to valid=False so the preview doesn't render a + degenerate near-zero-radius arc.""" + from mathutils import Vector + + from bonsai import tool + from bonsai.bim.module.model.mep import compute_bend_preview_polylines + + start_obj, start_pair = _mock_obj_with_axis((1, 0, 0), (3, 0, 0)) + end_obj, end_pair = _mock_obj_with_axis((-1, 0, 0), (-3, 0, 0)) + intersection = (Vector((0, 0, 0)), Vector((0, 0, 0))) + + with _with_axis_patches(start_pair, end_pair): + with patch.object(tool.Cad, "intersect_edges", return_value=intersection): + with patch.object( + tool.Cad, + "closest_and_furthest_vectors", + side_effect=lambda p, axis: (axis[0], axis[1]), + ): + result = compute_bend_preview_polylines(start_obj, end_obj, 0.1, 0.1, 0.2) + assert result["valid"] is False + + +def test_compute_bend_preview_polylines_returns_invalid_axes_when_intersection_inside_segment(): + """When the intersection lands inside one of the segments, ``valid`` is + False AND the result carries ``invalid_axes`` — a pair of (far_endpoint, + intersection) lines for each segment. ``BendPreviewDecorator`` reads + these to draw warning-red axes instead of rendering a degenerate arc.""" + from mathutils import Vector + + from bonsai import tool + from bonsai.bim.module.model.mep import compute_bend_preview_polylines + + start_obj, start_pair = _mock_obj_with_axis((-3, 0, 0), (-1, 0, 0)) + end_obj, end_pair = _mock_obj_with_axis((0, 5, 0), (0, 3, 0)) + intersection = (Vector((-2, 0, 0)), Vector((-2, 0, 0))) + + with _with_axis_patches(start_pair, end_pair): + with patch.object(tool.Cad, "intersect_edges", return_value=intersection): + with patch.object( + tool.Cad, + "closest_and_furthest_vectors", + # axis[0] = closer endpoint (near), axis[1] = farther (far). + side_effect=lambda p, axis: (axis[1], axis[0]), + ): + result = compute_bend_preview_polylines(start_obj, end_obj, 0.1, 0.1, 0.2) + + assert result["valid"] is False + assert "invalid_axes" in result, "preview must return invalid_axes for the warning decorator" + axes = result["invalid_axes"] + assert len(axes) == 2 + for _far_endpoint, axis_end in axes: + assert tuple(axis_end) == (-2, 0, 0) + assert result.get("reason") in ("intersection_inside_start", "intersection_inside_end") + + +# --------------------------------------------------------------------------- +# _intersection_past_near — degenerate-intersection guard for the preview +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "intersection,near,far,expected", + [ + # Normal: intersection past near, opposite side from far. + ((0, 0, 0), (-1, 0, 0), (-3, 0, 0), True), + # Degenerate: intersection BETWEEN near and far (inside the segment). + ((-2, 0, 0), (-1, 0, 0), (-3, 0, 0), False), + # Degenerate: intersection past FAR (opposite side from the bend). + ((-4, 0, 0), (-1, 0, 0), (-3, 0, 0), False), + # Borderline: intersection coincides with near — within tolerance → False. + ((-1, 0, 0), (-1, 0, 0), (-3, 0, 0), False), + # Degenerate: zero-length segment — can't classify, False. + ((0, 0, 0), (-1, 0, 0), (-1, 0, 0), False), + ], +) +def test_intersection_past_near(intersection, near, far, expected): + """Pins the degenerate-intersection classification used by + ``compute_bend_preview_polylines`` to reject in-segment intersections.""" + from mathutils import Vector + + from bonsai.bim.module.model.mep import _intersection_past_near + + assert _intersection_past_near(Vector(intersection), Vector(near), Vector(far)) is expected + + +# --------------------------------------------------------------------------- +# Registration probes +# --------------------------------------------------------------------------- + + +def test_bend_preview_operators_are_registered(): + """The three bend-preview operators must resolve via ``bpy.ops.bim.*`` — + enable populates scene props, finish dispatches ``bim.mep_add_bend`` + with the tuned params, cancel clears the state.""" + assert hasattr(bpy.ops.bim, "enable_bend_preview") + assert hasattr(bpy.ops.bim, "finish_bend_preview") + assert hasattr(bpy.ops.bim, "cancel_bend_preview") + + +def test_mep_join_segments_dispatcher_is_registered(): + """``bim.mep_join_segments`` is the discoverable entry point for the + bend preview flow (F3 search → "Join MEP Segments") until the full + gizmo-icon dispatch lands. Routes parallel → transition, non-parallel + → enable_bend_preview.""" + assert hasattr(bpy.ops.bim, "mep_join_segments") + + +def test_bend_preview_gizmo_group_is_registered(): + """``GizmoBendPreview`` polls when ``scene.BIMPreviewProperties.bend.is_active`` + is True. Pin the bl_idname so a typo wouldn't silently hide the preview + gizmos at runtime.""" + from bonsai.bim.module.model.mep_bend_preview import GizmoBendPreview + + assert GizmoBendPreview.bl_idname == "OBJECT_GGT_bim_bend_preview" + assert issubclass(GizmoBendPreview, bpy.types.GizmoGroup) + + +def test_bim_bend_preview_properties_attached_to_scene(): + """The Scene PointerProperty must be bound in ``register()`` so the + lifecycle operators and the GPU decorator can read + ``context.scene.BIMPreviewProperties.bend.is_active``.""" + assert hasattr(bpy.types.Scene, "BIMPreviewProperties") + assert hasattr(bpy.context.scene.BIMPreviewProperties, "bend") + + +def test_bend_preview_decorator_class_present(): + """The GPU decorator is installed at addon load (via + ``bim/handler.py:load_post``). Verify the class exists with the + install / uninstall interface the handler expects.""" + from bonsai.bim.module.model.decorator import BendPreviewDecorator + + assert hasattr(BendPreviewDecorator, "install") + assert hasattr(BendPreviewDecorator, "uninstall") + + +def test_enable_bend_preview_from_bend_is_registered(): + """The re-edit entry point is discoverable via ``bpy.ops.bim`` so the + pen-icon dispatch in ``GizmoMEPActions`` resolves at click time.""" + assert hasattr(bpy.ops.bim, "enable_bend_preview_from_bend") + + +def test_bim_bend_preview_properties_has_editing_bend_id(): + """The re-edit dispatch flag rides on the same preview PropertyGroup as + the rest of the bend draft state. Without this field on the umbrella, + re-edit cancel / commit cleanup would not zero it via + ``clear_preview_state`` (which iterates ``*_id`` IntProperty fields).""" + bend_props = bpy.context.scene.BIMPreviewProperties.bend + assert hasattr(bend_props, "editing_bend_id") + assert bend_props.editing_bend_id == 0 + + +@pytest.mark.parametrize( + "ifc_class,predefined_type,expected", + [ + ("IfcFlowFitting", "BEND", True), + ("IfcFlowFitting", "TRANSITION", False), + ("IfcFlowFitting", "OBSTRUCTION", False), + ("IfcFlowFitting", None, False), + ("IfcFlowSegment", "BEND", False), + ("IfcWall", "BEND", False), + ], +) +def test_is_bend_fitting_predicate_truth_table(ifc_class, predefined_type, expected): + """The predicate classifies each occurrence by walking up to its type's + ``PredefinedType``. Pin the four-way branch: matching class + matching + type, matching class + other type, wrong class, no type at all.""" + from unittest.mock import Mock + + from bonsai.bim.module.model.mep import _is_bend_fitting + + element = Mock() + element.is_a = Mock(side_effect=lambda c: c == ifc_class) + if predefined_type is None: + element_type = None + else: + element_type = Mock() + element_type.PredefinedType = predefined_type + + with patch("ifcopenshell.util.element.get_type", return_value=element_type): + assert _is_bend_fitting(element) is expected + + +def test_is_bend_fitting_predicate_returns_false_on_none(): + """The predicate is total — callers pass it raw ``tool.Ifc.get_entity`` + results which can be ``None`` for unbound Blender objects, and the + visibility-condition lambda must not raise from a gizmo poll.""" + from bonsai.bim.module.model.mep import _is_bend_fitting + + assert _is_bend_fitting(None) is False + + +# --------------------------------------------------------------------------- +# Finish-catches-RuntimeError contract +# --------------------------------------------------------------------------- + + +def test_finish_bend_preview_catches_runtime_error_from_dispatch(): + """When the dispatched ``bim.mep_add_bend`` reports ERROR + returns + CANCELLED, ``bpy.ops`` promotes that to RuntimeError. Finish must catch + it and return CANCELLED — propagating the exception leaves Blender's + operator state half-broken. Preview state must remain active so the + user can re-tune.""" + from types import SimpleNamespace + + from bonsai import tool + from bonsai.bim.module.model.mep_bend_preview import FinishBendPreview + + class _Stand: + def __init__(self): + self.report = MagicMock() + + op_self = _Stand() + fake_props = SimpleNamespace( + is_active=True, + start_segment_id=42, + end_segment_id=43, + start_length=0.1, + end_length=0.1, + radius=0.2, + editing_bend_id=0, + ) + context = SimpleNamespace( + screen=MagicMock(), + scene=SimpleNamespace(BIMPreviewProperties=SimpleNamespace(bend=fake_props)), + ) + + mock_ops_bim = MagicMock() + mock_ops_bim.mep_add_bend.side_effect = RuntimeError("synthetic dispatch error") + + with ( + patch.object(tool.Ifc, "get", return_value=MagicMock(name="ifc_file")), + patch.object(bpy.ops, "bim", new=mock_ops_bim), + ): + result = FinishBendPreview.execute(op_self, context) + + assert "CANCELLED" in result, "RuntimeError from dispatch must be converted to CANCELLED" + assert fake_props.is_active is True, "failed dispatch must leave preview active for re-tune" + op_self.report.assert_called() diff --git a/src/bonsai/test/bim/module/model/test_mep_bend_preview_cache.py b/src/bonsai/test/bim/module/model/test_mep_bend_preview_cache.py new file mode 100644 index 0000000000..52b232436a --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_mep_bend_preview_cache.py @@ -0,0 +1,188 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Cache-invalidation tests for ``cached_compute_bend_preview_polylines``. + +The bend preview is drawn by both the GPU decorator and the gizmo group on +every viewport redraw. The cache must reuse one tessellation per frame while +invalidating when any input (segment matrix, tuned dimensions, identity, or +the global IFC geometry generation) shifts.""" + +from unittest.mock import Mock, patch + +import pytest +from mathutils import Matrix + +pytestmark = pytest.mark.model + + +def _mock_obj(name: str, matrix: Matrix) -> Mock: + obj = Mock() + obj.name = name + obj.matrix_world = matrix + return obj + + +@pytest.fixture(autouse=True) +def _clear_memo(): + from bonsai.bim.module.model import mep + + mep._bend_preview_memo = None + yield + mep._bend_preview_memo = None + + +def _patches(call_count_sentinel: dict): + from bonsai import tool + from bonsai.bim.module.model import mep + + def counting_compute(*args, **kwargs): + call_count_sentinel["calls"] += 1 + return {"valid": True, "leg_a": None, "leg_b": None, "arc": []} + + return ( + patch.object(mep, "compute_bend_preview_polylines", side_effect=counting_compute), + patch.object(tool.Parametric, "get_geom_generation", return_value=call_count_sentinel.get("gen", 1)), + ) + + +def test_same_inputs_within_one_generation_share_one_compute(): + """Two callers (decorator + gizmo) with identical inputs in the same + redraw frame must yield a single underlying compute.""" + from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines + + a = _mock_obj("seg_a", Matrix.Identity(4)) + b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0))) + + sentinel = {"calls": 0, "gen": 7} + p_compute, p_gen = _patches(sentinel) + with p_compute, p_gen: + cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3) + cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3) + + assert sentinel["calls"] == 1 + + +def test_radius_change_invalidates_cache(): + from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines + + a = _mock_obj("seg_a", Matrix.Identity(4)) + b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0))) + + sentinel = {"calls": 0, "gen": 1} + p_compute, p_gen = _patches(sentinel) + with p_compute, p_gen: + cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3) + cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.4) # radius changed + + assert sentinel["calls"] == 2 + + +def test_start_length_change_invalidates_cache(): + from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines + + a = _mock_obj("seg_a", Matrix.Identity(4)) + b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0))) + + sentinel = {"calls": 0, "gen": 1} + p_compute, p_gen = _patches(sentinel) + with p_compute, p_gen: + cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3) + cached_compute_bend_preview_polylines(a, b, 0.15, 0.2, 0.3) # start_length changed + + assert sentinel["calls"] == 2 + + +def test_end_length_change_invalidates_cache(): + from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines + + a = _mock_obj("seg_a", Matrix.Identity(4)) + b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0))) + + sentinel = {"calls": 0, "gen": 1} + p_compute, p_gen = _patches(sentinel) + with p_compute, p_gen: + cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3) + cached_compute_bend_preview_polylines(a, b, 0.1, 0.25, 0.3) # end_length changed + + assert sentinel["calls"] == 2 + + +def test_segment_matrix_change_invalidates_cache(): + """Moving either segment changes the bend geometry — the cache must + recompute even when the IFC has not advanced.""" + from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines + + a = _mock_obj("seg_a", Matrix.Identity(4)) + b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0))) + + sentinel = {"calls": 0, "gen": 1} + p_compute, p_gen = _patches(sentinel) + with p_compute, p_gen: + cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3) + b.matrix_world = Matrix.Translation((2, 0, 0)) + cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3) + + assert sentinel["calls"] == 2 + + +def test_geom_generation_advance_invalidates_cache(): + """An IFC operator commit bumps ``tool.Parametric.get_geom_generation``; + the cache must recompute on the next call to pick up downstream geometry + changes that don't surface in the object's matrix_world.""" + from bonsai import tool + from bonsai.bim.module.model import mep + from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines + + a = _mock_obj("seg_a", Matrix.Identity(4)) + b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0))) + + sentinel = {"calls": 0} + + def counting_compute(*args, **kwargs): + sentinel["calls"] += 1 + return {"valid": True, "leg_a": None, "leg_b": None, "arc": []} + + gen_state = {"gen": 1} + with patch.object(mep, "compute_bend_preview_polylines", side_effect=counting_compute): + with patch.object(tool.Parametric, "get_geom_generation", side_effect=lambda: gen_state["gen"]): + cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3) + gen_state["gen"] = 2 + cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3) + + assert sentinel["calls"] == 2 + + +def test_swapping_one_segment_invalidates_cache(): + """Selecting a different segment pair (different object identity) must + recompute even when matrices coincidentally match.""" + from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines + + a = _mock_obj("seg_a", Matrix.Identity(4)) + b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0))) + c = _mock_obj("seg_c", Matrix.Translation((1, 0, 0))) + + sentinel = {"calls": 0, "gen": 1} + p_compute, p_gen = _patches(sentinel) + with p_compute, p_gen: + cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3) + cached_compute_bend_preview_polylines(a, c, 0.1, 0.2, 0.3) + + assert sentinel["calls"] == 2 diff --git a/src/bonsai/test/bim/module/model/test_mep_bend_tessellation.py b/src/bonsai/test/bim/module/model/test_mep_bend_tessellation.py new file mode 100644 index 0000000000..1c8e5c44b6 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_mep_bend_tessellation.py @@ -0,0 +1,202 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Pure-math tests for the bend tessellation helpers (FIXME #8106). + +Pins the geometry contracts the hand-meshed bend body relies on while the +upstream IfcSweptDiskSolid round-trip is broken: + +- profile cross-section sampling for circle / rectangle / unsupported +- parallel-transport framing along the centerline (the contract that + eliminates the twist a fixed world-reference basis produces) +- ``initial_basis`` override that aligns the cross-section with the + source segment's local +X / +Y axes (the asymmetric-rectangle fix)""" + +from math import cos, pi, sin +from unittest.mock import Mock + +import bpy +import pytest +from mathutils import Vector + +pytestmark = pytest.mark.model + + +# --------------------------------------------------------------------------- +# _bend_profile_cross_section — IFC profile → 2D sample points +# --------------------------------------------------------------------------- + + +def test_profile_cross_section_circle_returns_evenly_spaced_ring(): + """Circle profiles sample 16 points by default, equally spaced around + the radius. First vert sits at ``(radius, 0)`` so the mesh's local + angular zero aligns with the sweep basis ``right`` axis.""" + from bonsai.bim.module.model.mep import _bend_profile_cross_section + + profile = Mock() + profile.Radius = 0.1 + profile.is_a = lambda c: c == "IfcCircleProfileDef" + + pts = _bend_profile_cross_section(profile) + assert pts is not None + assert len(pts) == 16 + assert pts[0] == pytest.approx((0.1, 0.0)) + # All points lie on the circle. + for x, y in pts: + assert (x * x + y * y) == pytest.approx(0.1 * 0.1, abs=1e-9) + + +def test_profile_cross_section_circle_respects_n_circle_parameter(): + """The sample count is configurable; verify a non-default value + flows through to the result length.""" + from bonsai.bim.module.model.mep import _bend_profile_cross_section + + profile = Mock() + profile.Radius = 0.05 + profile.is_a = lambda c: c == "IfcCircleProfileDef" + + pts = _bend_profile_cross_section(profile, n_circle=8) + assert len(pts) == 8 + + +def test_profile_cross_section_rectangle_returns_four_corners(): + """Rectangle profiles return exactly four corners, in the canonical + ``[(-X/2,-Y/2), (X/2,-Y/2), (X/2,Y/2), (-X/2,Y/2)]`` winding.""" + from bonsai.bim.module.model.mep import _bend_profile_cross_section + + profile = Mock() + profile.XDim = 0.4 + profile.YDim = 0.2 + profile.is_a = lambda c: c == "IfcRectangleProfileDef" + + pts = _bend_profile_cross_section(profile) + assert pts == [(-0.2, -0.1), (0.2, -0.1), (0.2, 0.1), (-0.2, 0.1)] + + +def test_profile_cross_section_unsupported_returns_none(): + """Profiles other than circle / rectangle (e.g. + ``IfcArbitraryClosedProfileDef``) return ``None`` so the tessellation + helper skips the rep swap rather than building geometry against the + wrong cross-section.""" + from bonsai.bim.module.model.mep import _bend_profile_cross_section + + profile = Mock() + profile.is_a = lambda c: c == "IfcArbitraryClosedProfileDef" + + assert _bend_profile_cross_section(profile) is None + + +# --------------------------------------------------------------------------- +# _sweep_profile_along_polyline — vert + face count + parallel transport +# --------------------------------------------------------------------------- + + +def test_sweep_along_straight_polyline_builds_closed_tube_with_caps(): + """Straight 3-ring centerline + 4-vert profile yields 12 ring verts, + 3 quads × 4 sides = 12 side quads, plus two end-cap triangles per end + (4-vert profile fans into 2 triangles).""" + from bonsai.bim.module.model.mep import _sweep_profile_along_polyline + + centerline = [Vector((0.0, 0.0, 0.0)), Vector((0.0, 0.0, 1.0)), Vector((0.0, 0.0, 2.0))] + profile_2d = [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)] + + verts, faces = _sweep_profile_along_polyline(centerline, profile_2d) + + assert len(verts) == 3 * 4, "3 rings × 4 profile verts" + # 2 ring gaps × 4 quads each = 8 side faces; 2 caps × 2 triangles = 4 cap faces. + quad_count = sum(1 for f in faces if len(f) == 4) + tri_count = sum(1 for f in faces if len(f) == 3) + assert quad_count == 8, "one quad per profile edge per ring gap" + assert tri_count == 4, "fan triangulation gives n_profile - 2 = 2 tris per cap" + + +def test_sweep_parallel_transports_basis_around_right_angle_corner(): + """L-shaped centerline (turn from +Z to +X). After the corner, the + cross-section's reference direction is rotated 90° from before — the + parallel-transport invariant. Pin via the first verts of the start + and end rings: starts perpendicular to +Z (so in XY), ends + perpendicular to +X (so in YZ).""" + from bonsai.bim.module.model.mep import _sweep_profile_along_polyline + + centerline = [ + Vector((0.0, 0.0, 0.0)), + Vector((0.0, 0.0, 1.0)), + Vector((1.0, 0.0, 1.0)), + Vector((2.0, 0.0, 1.0)), + ] + # Single-vert profile would degenerate; use a 4-vert square so we + # have something to project onto each ring's basis. + profile_2d = [(0.1, 0.0), (0.0, 0.1), (-0.1, 0.0), (0.0, -0.1)] + + verts, _ = _sweep_profile_along_polyline(centerline, profile_2d) + + # First ring's verts must lie in a plane perpendicular to +Z (the + # tangent at the first ring). Verify each vert has |z-ring_center.z| ≈ 0. + first_ring = verts[0:4] + for v in first_ring: + assert v.z == pytest.approx(0.0, abs=1e-6), f"first-ring vert off the start plane: {v}" + + # Last ring's tangent is +X (last centerline segment). Verts should + # lie in a plane perpendicular to +X — i.e. x ≈ 2.0 (the centerline's + # x at the last ring). + last_ring = verts[-4:] + for v in last_ring: + assert v.x == pytest.approx(2.0, abs=1e-6), f"last-ring vert off the end plane: {v}" + + +def test_sweep_initial_basis_override_aligns_first_ring_with_segment_axes(): + """The asymmetric-rectangle fix: caller supplies the segment's local + +X / +Y axes (in world space) as ``initial_basis``; the helper uses + those as the first ring's basis instead of the world-Z seed. Verify + by checking that the first profile vert lands at ``ring0 + right * + sx + up * sy`` for the provided right / up.""" + from bonsai.bim.module.model.mep import _sweep_profile_along_polyline + + centerline = [Vector((0.0, 0.0, 0.0)), Vector((0.0, 0.0, 1.0))] + # Profile sample at (0.5, 0) — a single point on the +X profile axis. + profile_2d = [(0.5, 0.0)] + + # Initial basis where right = +Y world, up = +X world (rotated 90° + # from the default world-Z seed which would give right ≈ -Y). + initial_basis = (Vector((0.0, 1.0, 0.0)), Vector((1.0, 0.0, 0.0))) + + verts, _ = _sweep_profile_along_polyline(centerline, profile_2d, initial_basis=initial_basis) + + # First vert = ring0 (0,0,0) + right * 0.5 + up * 0 = (0, 0.5, 0). + assert tuple(verts[0]) == pytest.approx((0.0, 0.5, 0.0), abs=1e-6) + + +def test_sweep_default_seed_uses_world_z_reference(): + """Without an ``initial_basis``, the helper falls back to a stable + world-Z reference for the first ring. Pin so a future refactor of + the fallback doesn't silently change the orientation for callers + that rely on the default (the bend preview decorator's debug draw + path, for instance).""" + from bonsai.bim.module.model.mep import _sweep_profile_along_polyline + + centerline = [Vector((0.0, 0.0, 0.0)), Vector((1.0, 0.0, 0.0))] + profile_2d = [(1.0, 0.0)] + + verts, _ = _sweep_profile_along_polyline(centerline, profile_2d) + + # First tangent = +X. world-Z up_ref → right = tangent × up_ref = + # (1,0,0) × (0,0,1) = (0,-1,0). up = right × tangent = (0,0,1). + # First vert at right * 1.0 = (0, -1, 0). + assert tuple(verts[0]) == pytest.approx((0.0, -1.0, 0.0), abs=1e-6) diff --git a/src/bonsai/test/bim/module/model/test_mep_distribution_fit_smoke.py b/src/bonsai/test/bim/module/model/test_mep_distribution_fit_smoke.py new file mode 100644 index 0000000000..55d325d76c --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_mep_distribution_fit_smoke.py @@ -0,0 +1,227 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Smoke coverage for ``RegenerateDistributionElement`` and +``FitFlowSegments``. + +Both operators carry substantial branching that the bend / port test +files don't reach. These tests pin: + +- the operator-registration contract (bl_idname / bl_label / bl_options), +- ``FitFlowSegments`` dispatch table — 0 / 1 / mixed-class selections + resolve to the documented no-op or operator dispatch without raising, +- ``RegenerateDistributionElement`` runs on a leaf element (no connected + neighbours) without crashing on the recursion entry point. + +Deeper geometry-tree behaviour (multi-branch traversal, port-aligned +translation, segment regrowth) is deferred to integration testing +against real IFC fixtures; the smoke tests are explicitly the +oversight-prevention floor, not the full contract.""" + +from unittest.mock import MagicMock, Mock, patch + +import bpy +import pytest + +pytestmark = pytest.mark.model + + +def _segment(ifc_class: str = "IfcFlowSegment"): + """Stand-in for an IfcFlowSegment / subclass entity. + + ``is_a("IfcFlowSegment" | )`` returns True; ``is_a()`` with + no args returns the class name (the IfcOpenShell API exposes both + forms — ``FitFlowSegments`` calls ``element.is_a()`` to record the + selection's class for the mixed-class refusal check).""" + + def fake_is_a(c=None): + if c is None: + return ifc_class + return c in {"IfcFlowSegment", ifc_class} + + e = Mock() + e.is_a = fake_is_a + return e + + +def _make_op(**fields): + op = Mock() + for k, v in fields.items(): + setattr(op, k, v) + op.report = MagicMock() + return op + + +# --------------------------------------------------------------------------- +# Registration smoke +# --------------------------------------------------------------------------- + + +def test_regenerate_distribution_element_is_registered(): + """``RegenerateDistributionElement`` is the entry point for the + distribution-tree repropagation. Pin the bl_idname so a typo in the + classes tuple wouldn't silently drop the operator.""" + from bonsai.bim.module.model import mep + + assert mep.RegenerateDistributionElement.bl_idname == "bim.regenerate_distribution_element" + assert mep.RegenerateDistributionElement.bl_label == "Regenerate Distribution Element" + assert mep.RegenerateDistributionElement.bl_options == {"REGISTER", "UNDO"} + + +def test_fit_flow_segments_is_registered(): + """``FitFlowSegments`` is the cursor-based "add a fitting from the + current selection" entry point. Pin the registration contract so the + operator stays callable from the workspace tool.""" + from bonsai.bim.module.model import mep + + assert mep.FitFlowSegments.bl_idname == "bim.fit_flow_segments" + assert mep.FitFlowSegments.bl_label == "Fit Flow Segments" + assert mep.FitFlowSegments.bl_options == {"REGISTER", "UNDO"} + + +# --------------------------------------------------------------------------- +# FitFlowSegments dispatch table +# --------------------------------------------------------------------------- + + +def test_fit_flow_segments_with_no_selection_is_noop(): + """Nothing selected → no fitting type resolved → operator returns + without dispatching any ``bim.mep_add_*`` op. The user-facing + contract is "this is a tool you fire with a selection"; the silent + no-op on empty selection is intentional (no popup, no error).""" + from bonsai.bim.module.model import mep + + context = MagicMock() + context.selected_objects = [] + + op = _make_op() + with patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object( + mep.MEPAddBend, "_execute", return_value=None + ) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition: + mep.FitFlowSegments._execute(op, context=context) + + obstruction.assert_not_called() + bend.assert_not_called() + transition.assert_not_called() + + +def test_fit_flow_segments_with_single_segment_dispatches_obstruction(): + """Exactly one IfcFlowSegment selected → OBSTRUCTION fitting type, + delegates to ``bim.mep_add_obstruction`` which handles the + cursor-anchored placement. + + ``bpy.ops`` resolves operator dispatch through Blender's internal id + table, not through Python attribute access, so a Python-level patch + on ``bpy.ops.bim.mep_add_obstruction`` doesn't intercept the call. + Patch the operator's ``_execute`` instead — same effect, exercises + the real dispatch path that the user hits at runtime.""" + from bonsai.bim.module.model import mep + + segment_obj = MagicMock() + segment_profile = MagicMock() + segment_entity = _segment("IfcPipeSegment") + + context = MagicMock() + context.selected_objects = [segment_obj] + + op = _make_op() + with patch.object(mep.tool.Ifc, "get_entity", return_value=segment_entity), patch.object( + mep.tool.Model, "get_flow_segment_profile", return_value=segment_profile + ), patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object( + mep.MEPAddBend, "_execute", return_value=None + ) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition: + mep.FitFlowSegments._execute(op, context=context) + + assert obstruction.call_count == 1 + bend.assert_not_called() + transition.assert_not_called() + + +def test_fit_flow_segments_refuses_mixed_pipe_and_duct(): + """Selecting one IfcPipeSegment + one IfcDuctSegment → the operator + bails out before any fitting dispatch. The user-facing path is + "select segments of one kind"; mixing pipe + duct would create an + invalid IFC fitting type.""" + from bonsai.bim.module.model import mep + + pipe_obj = MagicMock() + duct_obj = MagicMock() + pipe_entity = _segment("IfcPipeSegment") + duct_entity = _segment("IfcDuctSegment") + profile = MagicMock() + + context = MagicMock() + context.selected_objects = [pipe_obj, duct_obj] + + def fake_get_entity(obj): + return pipe_entity if obj is pipe_obj else duct_entity + + op = _make_op() + with patch.object(mep.tool.Ifc, "get_entity", side_effect=fake_get_entity), patch.object( + mep.tool.Model, "get_flow_segment_profile", return_value=profile + ), patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object( + mep.MEPAddBend, "_execute", return_value=None + ) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition: + mep.FitFlowSegments._execute(op, context=context) + + obstruction.assert_not_called() + bend.assert_not_called() + transition.assert_not_called() + + +# --------------------------------------------------------------------------- +# RegenerateDistributionElement +# --------------------------------------------------------------------------- + + +def test_regenerate_distribution_element_on_leaf_is_safe(): + """A distribution element with no connected neighbours → the inner + queue stays empty → the operator returns cleanly without entering + the per-branch processing path. + + This pins the safety floor: the recursion entry point should not + crash on a single-element graph, which is the most common shape + when a user fires this operator on an isolated segment.""" + from bonsai.bim.module.model import mep + + leaf_element = _segment("IfcPipeSegment") + leaf_obj = MagicMock() + + context = MagicMock() + context.active_object = leaf_obj + + fake_active = MagicMock() + fake_active.is_a = lambda c: False # bpy.context.active_object stub + + op = _make_op() + with patch.object(mep.tool.Ifc, "get_entity", return_value=leaf_element), patch( + "ifcopenshell.util.system.get_connected_to", return_value=[] + ), patch("ifcopenshell.util.system.get_connected_from", return_value=[]), patch.object( + mep.tool.Ifc, "get", return_value=MagicMock() + ), patch( + "ifcopenshell.util.unit.calculate_unit_scale", return_value=1.0 + ), patch.object( + bpy, "context", new=context + ): + mep.RegenerateDistributionElement._execute(op, context=context) + + # The contract on a leaf is "nothing to do". No exception, no IFC + # mutation. The bpy.ops dispatch table inside process_branch never + # fires because queue is empty. diff --git a/src/bonsai/test/bim/module/model/test_mep_port_operators.py b/src/bonsai/test/bim/module/model/test_mep_port_operators.py new file mode 100644 index 0000000000..c434c69020 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_mep_port_operators.py @@ -0,0 +1,388 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Behaviour tests for the MEP port operators. + +Pins the dispatch contract each operator carries — which IFC mutation +runs, which user-error path returns CANCELLED, and which fitting types +are deliberately refused by each entry point. Each test mocks the +``tool.*`` and ``MEPGenerator`` boundaries so no IFC fixture is needed.""" + +from unittest.mock import MagicMock, Mock, patch + +import bpy +import pytest + +pytestmark = pytest.mark.model + + +def _segment(predefined_type=None): + """Stand-in IFC entity that reports ``is_a("IfcFlowSegment")`` True.""" + e = Mock() + e.is_a = lambda c: c == "IfcFlowSegment" + e.PredefinedType = predefined_type + return e + + +def _fitting(predefined_type=None): + """Stand-in IFC fitting entity with an arbitrary ``PredefinedType``.""" + e = Mock() + e.is_a = lambda c: c in ("IfcFlowFitting", "IfcDistributionFlowElement") + e.PredefinedType = predefined_type + return e + + +def _make_op(_cls, **fields): + """Return a Mock standing in for an Operator ``self``. Subclassing a + ``bpy.types.Operator`` outside Blender's registration machinery raises + a ``bpy_struct.__new__`` error, so each test calls the operator + method as an unbound function with this Mock as the first argument.""" + op = Mock() + for k, v in fields.items(): + setattr(op, k, v) + op.report = MagicMock() + return op + + +# --------------------------------------------------------------------------- +# MEPUnjoinAtPort +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "port_state, fitting_predefined_type, expected_result, expects_delete", + [ + pytest.param("JOINED", "JUNCTION", {"FINISHED"}, True, id="joined_junction_deletes"), + pytest.param("JOINED", "OBSTRUCTION", {"CANCELLED"}, False, id="joined_obstruction_refused"), + pytest.param("FREE", None, {"CANCELLED"}, False, id="free_port_cancels"), + ], +) +def test_unjoin_at_port_dispatch_table(port_state, fitting_predefined_type, expected_result, expects_delete): + """``MEPUnjoinAtPort`` dispatch contract: result and delete-side-effect + by ``(port_state, fitting type)``. + + - ``JOINED + JUNCTION`` (or any non-OBSTRUCTION fitting): happy path, + the bridging fitting is deleted via the standard delete entry point. + - ``JOINED + OBSTRUCTION``: deliberately refused — obstructions go + through ``bim.mep_add_obstruction`` (mode=REMOVE) so the segment + extends to absorb the freed length; using delete here would leave + a visible gap. + - ``FREE``: nothing to do — no bridging fitting exists. The operator + reports a user-facing error and CANCELS rather than no-op silently.""" + from bonsai.bim.module.model import mep + + segment = _segment() + fitting = _fitting(predefined_type=fitting_predefined_type) if fitting_predefined_type else None + fitting_obj = Mock() + + op = _make_op(mep.MEPUnjoinAtPort, segment_id=42, position="END") + ifc_file = MagicMock() + ifc_file.by_id.return_value = segment + + with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object( + mep.tool.Ifc, "get_object", return_value=fitting_obj + ), patch.object(mep, "port_connection_state", return_value=port_state), patch.object( + mep, "get_connected_element_at_segment_port", return_value=fitting + ), patch.object( + mep.tool.Geometry, "delete_ifc_object" + ) as delete: + result = mep.MEPUnjoinAtPort._execute(op, context=MagicMock()) + + assert result == expected_result + if expects_delete: + delete.assert_called_once_with(fitting_obj) + else: + delete.assert_not_called() + op.report.assert_called() + + +def test_unjoin_at_port_cancels_when_active_is_not_segment(): + """The operator only operates on flow segments; non-segment active + objects must fail loud rather than mutate something unexpected.""" + from bonsai.bim.module.model import mep + + fitting = _fitting() # IfcFlowFitting, not IfcFlowSegment + + op = _make_op(mep.MEPUnjoinAtPort, segment_id=42, position="END") + ifc_file = MagicMock() + ifc_file.by_id.return_value = fitting + + with patch.object(mep.tool.Ifc, "get", return_value=ifc_file): + result = mep.MEPUnjoinAtPort._execute(op, context=MagicMock()) + + assert result == {"CANCELLED"} + op.report.assert_called() + + +# --------------------------------------------------------------------------- +# MEPRemoveTerminalFitting +# --------------------------------------------------------------------------- + + +def test_remove_terminal_dispatches_obstruction_via_remove_obstruction(): + """OBSTRUCTION fittings extend the segment to absorb the freed length; + the operator routes through ``MEPGenerator().remove_obstruction`` + rather than the plain delete path.""" + from bonsai.bim.module.model import mep + + segment = _segment() + obstruction = _fitting(predefined_type="OBSTRUCTION") + + op = _make_op(mep.MEPRemoveTerminalFitting, segment_id=42, position="END") + ifc_file = MagicMock() + ifc_file.by_id.return_value = segment + + with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object( + mep, "port_connection_state", return_value="TERMINAL" + ), patch.object(mep, "get_connected_element_at_segment_port", return_value=obstruction), patch.object( + mep, "MEPGenerator" + ) as gen_cls, patch.object( + mep.tool.Geometry, "delete_ifc_object" + ) as delete: + gen_cls.return_value.remove_obstruction.return_value = (obstruction, None) + result = mep.MEPRemoveTerminalFitting._execute(op, context=MagicMock()) + + assert result == {"FINISHED"} + gen_cls.return_value.remove_obstruction.assert_called_once_with(segment, False) + delete.assert_not_called() + + +def test_remove_terminal_dispatches_non_obstruction_via_delete(): + """A standard terminal fitting (cap, isolated terminal) goes through + the plain delete path — the segment is not resized.""" + from bonsai.bim.module.model import mep + + segment = _segment() + fitting = _fitting(predefined_type=None) + fitting_obj = Mock() + + op = _make_op(mep.MEPRemoveTerminalFitting, segment_id=42, position="END") + ifc_file = MagicMock() + ifc_file.by_id.return_value = segment + + with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object( + mep.tool.Ifc, "get_object", return_value=fitting_obj + ), patch.object(mep, "port_connection_state", return_value="TERMINAL"), patch.object( + mep, "get_connected_element_at_segment_port", return_value=fitting + ), patch.object( + mep.tool.Geometry, "delete_ifc_object" + ) as delete: + result = mep.MEPRemoveTerminalFitting._execute(op, context=MagicMock()) + + assert result == {"FINISHED"} + delete.assert_called_once_with(fitting_obj) + + +def test_remove_terminal_cancels_on_non_terminal_port(): + """Port state must be TERMINAL for this operator; FREE / JOINED are + routed through other operators.""" + from bonsai.bim.module.model import mep + + segment = _segment() + + op = _make_op(mep.MEPRemoveTerminalFitting, segment_id=42, position="END") + ifc_file = MagicMock() + ifc_file.by_id.return_value = segment + + with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object( + mep, "port_connection_state", return_value="JOINED" + ): + result = mep.MEPRemoveTerminalFitting._execute(op, context=MagicMock()) + + assert result == {"CANCELLED"} + op.report.assert_called() + + +# --------------------------------------------------------------------------- +# MEPUnjoinPair +# --------------------------------------------------------------------------- + + +def test_unjoin_pair_deletes_bridging_fitting(): + """Happy path: two selected segments share a single non-OBSTRUCTION + bridging fitting → delete it.""" + from bonsai.bim.module.model import mep + + segment_a = _segment() + segment_b = _segment() + fitting = _fitting(predefined_type="JUNCTION") + fitting_obj = Mock() + + op = _make_op(mep.MEPUnjoinPair) + selected = [Mock(), Mock()] + + with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object( + mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b] + ), patch.object(mep, "find_fitting_between_segments", return_value=fitting), patch.object( + mep.tool.Ifc, "get_object", return_value=fitting_obj + ), patch.object( + mep.tool.Geometry, "delete_ifc_object" + ) as delete: + result = mep.MEPUnjoinPair._execute(op, context=MagicMock()) + + assert result == {"FINISHED"} + delete.assert_called_once_with(fitting_obj) + + +def test_unjoin_pair_refuses_obstruction_bridging(): + """Same defence-in-depth as ``MEPUnjoinAtPort`` — obstructions go + through the dedicated REMOVE path; this operator surfaces the + redirect rather than silently doing the wrong thing.""" + from bonsai.bim.module.model import mep + + segment_a = _segment() + segment_b = _segment() + obstruction = _fitting(predefined_type="OBSTRUCTION") + + op = _make_op(mep.MEPUnjoinPair) + selected = [Mock(), Mock()] + + with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object( + mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b] + ), patch.object(mep, "find_fitting_between_segments", return_value=obstruction), patch.object( + mep.tool.Geometry, "delete_ifc_object" + ) as delete: + result = mep.MEPUnjoinPair._execute(op, context=MagicMock()) + + assert result == {"CANCELLED"} + delete.assert_not_called() + op.report.assert_called() + + +def test_unjoin_pair_reports_when_no_bridging_fitting_found(): + """The pair is selected but no single fitting bridges them — the + user is told instead of getting a silent no-op.""" + from bonsai.bim.module.model import mep + + segment_a = _segment() + segment_b = _segment() + + op = _make_op(mep.MEPUnjoinPair) + selected = [Mock(), Mock()] + + with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object( + mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b] + ), patch.object(mep, "find_fitting_between_segments", return_value=None), patch.object( + mep.tool.Geometry, "delete_ifc_object" + ) as delete: + result = mep.MEPUnjoinPair._execute(op, context=MagicMock()) + + assert result == {"CANCELLED"} + delete.assert_not_called() + op.report.assert_called() + + +def test_unjoin_pair_cancels_when_selection_is_not_two_segments(): + """The poll filters the gizmo, but a programmatic invocation could + still hand the operator an invalid selection. The execute path + independently verifies both inputs are IfcFlowSegment.""" + from bonsai.bim.module.model import mep + + not_a_segment = _fitting() # IfcFlowFitting, not IfcFlowSegment + + op = _make_op(mep.MEPUnjoinPair) + selected = [Mock(), Mock()] + + with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object( + mep.tool.Ifc, "get_entity", side_effect=[not_a_segment, not_a_segment] + ): + result = mep.MEPUnjoinPair._execute(op, context=MagicMock()) + + assert result == {"CANCELLED"} + op.report.assert_called() + + +# --------------------------------------------------------------------------- +# SelectMEPPathMembers +# --------------------------------------------------------------------------- + + +def test_select_path_replaces_selection_with_walked_members(): + """Happy path: walker returns a small connected network → every + member gets ``select_set(True)``; the original active object stays + active.""" + from bonsai.bim.module.model import mep + + active = Mock() + element = Mock() + member_elements = [Mock(), Mock(), Mock()] + member_objs = [Mock(), Mock(), Mock()] + + context = MagicMock() + context.active_object = active + context.view_layer.objects.active = None + + op = _make_op(mep.SelectMEPPathMembers) + + with patch.object(mep.tool.Ifc, "get_entity", return_value=element), patch.object( + mep.tool.System, "walk_connected_mep_elements", return_value=member_elements + ), patch.object(mep.tool.Ifc, "get_object", side_effect=member_objs), patch.object( + mep.bpy.ops.object, "select_all" + ): + result = mep.SelectMEPPathMembers.execute(op, context) + + assert result == {"FINISHED"} + for obj in member_objs: + obj.select_set.assert_called_once_with(True) + + +def test_select_path_reports_when_walker_returns_empty(): + """An MEP element with no connected neighbours produces an empty + walk; report INFO so the user knows the click registered, return + FINISHED so the operator doesn't surface as an error.""" + from bonsai.bim.module.model import mep + + active = Mock() + element = Mock() + + context = MagicMock() + context.active_object = active + + op = _make_op(mep.SelectMEPPathMembers) + + with patch.object(mep.tool.Ifc, "get_entity", return_value=element), patch.object( + mep.tool.System, "walk_connected_mep_elements", return_value=[] + ): + result = mep.SelectMEPPathMembers.execute(op, context) + + assert result == {"FINISHED"} + op.report.assert_called() + + +def test_select_path_handles_walker_exception(): + """The walker can raise on malformed port graphs; the operator must + catch and surface as ERROR rather than crashing the operator harness.""" + from bonsai.bim.module.model import mep + + active = Mock() + element = Mock() + + context = MagicMock() + context.active_object = active + + op = _make_op(mep.SelectMEPPathMembers) + + with patch.object(mep.tool.Ifc, "get_entity", return_value=element), patch.object( + mep.tool.System, "walk_connected_mep_elements", side_effect=RuntimeError("malformed port graph") + ): + result = mep.SelectMEPPathMembers.execute(op, context) + + assert result == {"CANCELLED"} + op.report.assert_called() diff --git a/src/bonsai/test/bim/module/model/test_mep_segment_edition.py b/src/bonsai/test/bim/module/model/test_mep_segment_edition.py new file mode 100644 index 0000000000..2451ceb7e2 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_mep_segment_edition.py @@ -0,0 +1,533 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Unit tests for the pipe/duct segment parametric-edit scaffolding. + +Covers three surfaces that ship together as the first MEP dimension-gizmo +feature: + +- ``tool.Parametric.is_pipe_segment`` / ``is_duct_segment`` predicates + (registry contract — must be total). +- ``_segment_world_length`` / ``_preview_segment_via_scale`` / + ``_restore_segment_scale`` pure helpers driving the live preview. +- ``GizmoPipeSegmentEdition`` / ``GizmoDuctSegmentEdition`` class wiring + (bl_idname, operator bindings, dimension_gizmo_props, is_element_type). + +Full operator round-trips (enable → drag → finish → IFC commit) need a real +Blender + IFC scene and are deferred to a later integration session.""" + +from unittest.mock import Mock, patch + +import bpy +import ifcopenshell +import pytest +from mathutils import Matrix, Vector + +pytestmark = pytest.mark.model + + +# --------------------------------------------------------------------------- +# Predicates — total over arbitrary IFC entity input +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "ifc_class,is_pipe_expected,is_duct_expected", + [ + ("IfcPipeSegment", True, False), + ("IfcDuctSegment", False, True), + ("IfcFlowSegment", False, False), # base class — neither pipe nor duct alone + ("IfcPipeFitting", False, False), # fitting, not a segment + ("IfcDuctFitting", False, False), + ("IfcWall", False, False), + ("IfcAnnotation", False, False), # bare schema element with no MEP semantics + ], +) +def test_is_pipe_or_duct_segment_predicate_truth_table(ifc_class, is_pipe_expected, is_duct_expected): + """The two predicates must classify every IFC class correctly AND + return False (not raise) on classes that have nothing to do with MEP. + Pinned alongside the registry-wide predicate-totality test so a + regression in either direction surfaces in this file too.""" + from bonsai import tool + + probe = ifcopenshell.file(schema="IFC4").create_entity(ifc_class) + assert tool.Parametric.is_pipe_segment(probe) is is_pipe_expected + assert tool.Parametric.is_duct_segment(probe) is is_duct_expected + + +# --------------------------------------------------------------------------- +# _segment_world_length — pure geometric helper +# --------------------------------------------------------------------------- + + +def test_segment_world_length_returns_axis_magnitude(): + """The length read here drives both the dimension gizmo's display and + the snap_length captured on enable. Pin the math on a known axis.""" + from bonsai.bim.module.model.mep import _segment_world_length + + fake_obj = object() + axis = (Vector((1.0, 2.0, 3.0)), Vector((1.0, 2.0, 5.5))) + with patch("bonsai.tool.Model.get_flow_segment_axis", return_value=axis): + assert _segment_world_length(fake_obj) == pytest.approx(2.5) + + +# --------------------------------------------------------------------------- +# Preview helpers — obj.scale.z manipulation +# --------------------------------------------------------------------------- + + +class _FakeObj: + """Stand-in for bpy.types.Object exposing only ``scale`` — enough for + the preview helpers, which never touch IFC.""" + + def __init__(self): + self.scale = Vector((1.0, 1.0, 1.0)) + + +def test_preview_segment_via_scale_sets_z_to_ratio(): + """The visible-stretch ratio composes ``props_length / mesh_local_length`` where + ``mesh_local_length = snap_length / snap_object_scale_z``.""" + from bonsai.bim.module.model.mep import _preview_segment_via_scale + + obj = _FakeObj() + _preview_segment_via_scale(obj, props_length=2.0, snap_length=1.0, snap_object_scale_z=1.0) + assert obj.scale.z == pytest.approx(2.0) + + _preview_segment_via_scale(obj, props_length=0.5, snap_length=1.0, snap_object_scale_z=1.0) + assert obj.scale.z == pytest.approx(0.5) + + +def test_preview_segment_via_scale_floors_at_min_value(): + """``props.length`` is clamped at FloatProperty min=0.01; the helper still + defends against zero / negative so a runaway value can't invert the segment.""" + from bonsai.bim.module.model.mep import _preview_segment_via_scale + + obj = _FakeObj() + _preview_segment_via_scale(obj, props_length=0.0, snap_length=1.0, snap_object_scale_z=1.0) + assert obj.scale.z == pytest.approx(0.01) + + +def test_preview_segment_via_scale_skips_when_snap_is_zero(): + """A zero ``snap_length`` would divide by zero — helper skips silently.""" + from bonsai.bim.module.model.mep import _preview_segment_via_scale + + obj = _FakeObj() + obj.scale.z = 3.0 + _preview_segment_via_scale(obj, props_length=1.0, snap_length=0.0, snap_object_scale_z=1.0) + # No change. + assert obj.scale.z == pytest.approx(3.0) + + +def test_restore_segment_scale_resets_z_to_target(): + """Pin that the reset only touches Z; X/Y stay whatever the user set.""" + from bonsai.bim.module.model.mep import _restore_segment_scale_to + + obj = _FakeObj() + obj.scale = Vector((0.5, 0.7, 4.2)) + _restore_segment_scale_to(obj, 1.0) + assert obj.scale.x == pytest.approx(0.5) + assert obj.scale.y == pytest.approx(0.7) + assert obj.scale.z == pytest.approx(1.0) + + +# --------------------------------------------------------------------------- +# Gizmo group class wiring — registration and config +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "gizmo_cls_name,bl_idname,is_element_predicate", + [ + ("GizmoPipeSegmentEdition", "OBJECT_GGT_bim_pipe_segment_edition", "is_pipe_segment"), + ("GizmoDuctSegmentEdition", "OBJECT_GGT_bim_duct_segment_edition", "is_duct_segment"), + ], +) +def test_gizmo_group_class_wiring(gizmo_cls_name, bl_idname, is_element_predicate): + """Each gizmo group must: + - declare the expected ``bl_idname`` (so it actually registers under that name); + - have the matching ``is_element_type`` delegate to the right predicate + (so it polls in for the right IFC class). + """ + from bonsai import tool + from bonsai.bim.module.model import mep + + cls = getattr(mep, gizmo_cls_name) + assert cls.bl_idname == bl_idname + predicate = getattr(tool.Parametric, is_element_predicate) + fake_element = Mock() + fake_element.is_a.return_value = True + with patch.object(tool.Parametric, is_element_predicate, side_effect=predicate) as p, patch.object( + tool.System, "has_parametric_body", return_value=True + ): + cls.is_element_type(fake_element) + assert p.called, f"{gizmo_cls_name}.is_element_type did not delegate to Parametric.{is_element_predicate}" + + +@pytest.mark.parametrize( + "gizmo_cls_name,enable_op,finish_op,cancel_op", + [ + ( + "GizmoPipeSegmentEdition", + "bim.enable_editing_pipe_segment", + "bim.finish_editing_pipe_segment", + "bim.cancel_editing_pipe_segment", + ), + ( + "GizmoDuctSegmentEdition", + "bim.enable_editing_duct_segment", + "bim.finish_editing_duct_segment", + "bim.cancel_editing_duct_segment", + ), + ], +) +def test_gizmo_lifecycle_bindings_reference_registered_operators(gizmo_cls_name, enable_op, finish_op, cancel_op): + """Catches the silent-regression where the gizmo's enable/finish/cancel + string drifts away from the actual operator ``bl_idname``.""" + from bonsai.bim.module.model import mep + + cls = getattr(mep, gizmo_cls_name) + assert cls.enable_editing_operator == enable_op + assert cls.finish_editing_operator == finish_op + assert cls.cancel_editing_operator == cancel_op + # And the operators are actually registered. + for op in (enable_op, finish_op, cancel_op): + namespace, _, verb = op.partition(".") + assert hasattr( + getattr(bpy.ops, namespace), verb + ), f"{gizmo_cls_name} references {op!r} which is not a registered operator" + + +@pytest.mark.parametrize("gizmo_cls_name", ["GizmoPipeSegmentEdition", "GizmoDuctSegmentEdition"]) +def test_gizmo_dimension_gizmo_props_has_single_length_entry(gizmo_cls_name): + """Phase 1 ships a single dimension (segment length). Pin the shape so + a Phase 2 addition (diameter / width / height) is an intentional + expansion rather than a drive-by edit.""" + from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig + from bonsai.bim.module.model import mep + + cls = getattr(mep, gizmo_cls_name) + assert len(cls.dimension_gizmo_props) == 1 + config = cls.dimension_gizmo_props[0] + assert isinstance(config, DimensionGizmoConfig) + assert config.attr_name == "length" + assert tuple(config.axis) == (0, 0, 1) + assert config.min_value == pytest.approx(0.01) + + +@pytest.mark.parametrize("gizmo_cls_name", ["GizmoPipeSegmentEdition", "GizmoDuctSegmentEdition"]) +def test_length_dimension_has_matrix_position_so_rotation_is_respected(gizmo_cls_name): + """Regression guard for "edit-mode length dimension doesn't take local + object rotation". Without ``matrix_position`` set, ``update_dimension_gizmos`` + falls back to ``base_matrix = Identity`` and the gizmo's intrinsic +X + visual line is never rotated to the configured ``axis`` — the dimension + renders perpendicular to the segment on a rotated pipe. Setting + ``matrix_position`` (even to ``(0, 0, 0)``) routes through + ``compose_gizmo_matrix`` which applies ``get_axis_rotation_matrix(axis)`` + so the line aligns with the segment's local +Z (extrusion axis) in + world space.""" + from bonsai.bim.module.model import mep + + cls = getattr(mep, gizmo_cls_name) + config = cls.dimension_gizmo_props[0] + assert config.matrix_position is not None, ( + f"{gizmo_cls_name} length dimension is missing matrix_position — the gizmo will " + "render along the object's local +X axis instead of the segment's local +Z." + ) + + +# --------------------------------------------------------------------------- +# Extend-to-cursor — operator + element-specific gizmo wiring +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "gizmo_cls_name,extend_operator", + [ + ("GizmoPipeSegmentEdition", "bim.extend_pipe_segment_to_cursor"), + ("GizmoDuctSegmentEdition", "bim.extend_duct_segment_to_cursor"), + ], +) +def test_extend_operator_binding(gizmo_cls_name, extend_operator): + """Each segment gizmo group must reference the matching extend operator + AND that operator must actually be registered. Catches the silent + regression where someone renames the extend bl_idname without updating + the gizmo group's ``_extend_operator`` class attribute.""" + from bonsai.bim.module.model import mep + + cls = getattr(mep, gizmo_cls_name) + assert cls._extend_operator == extend_operator + namespace, _, verb = extend_operator.partition(".") + assert hasattr( + getattr(bpy.ops, namespace), verb + ), f"{gizmo_cls_name} references {extend_operator!r} which is not a registered operator" + + +@pytest.mark.parametrize("feature_attr", ["pipe_segment", "duct_segment"]) +def test_gizmo_preferences_field_exists(feature_attr): + """``GizmoPreferences`` must carry pipe_segment + duct_segment PointerProperties + so ``get_gizmo_prefs()`` on the MEP gizmo groups resolves to a real PropertyGroup.""" + import bonsai.bim.ui as ui + + assert feature_attr in ui.GizmoPreferences.__annotations__, ( + f"GizmoPreferences is missing the {feature_attr} PointerProperty; " + f"MEP gizmo groups' get_gizmo_prefs() would raise AttributeError." + ) + + +# --------------------------------------------------------------------------- +# Lifecycle operators are registered +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "op", + [ + "bim.enable_editing_pipe_segment", + "bim.finish_editing_pipe_segment", + "bim.cancel_editing_pipe_segment", + "bim.extend_pipe_segment_to_cursor", + "bim.enable_editing_duct_segment", + "bim.finish_editing_duct_segment", + "bim.cancel_editing_duct_segment", + "bim.extend_duct_segment_to_cursor", + ], +) +def test_segment_operators_are_registered(op): + """Smoke test mirroring ``test_parametric_registry``'s + ``test_every_entry_has_enable_op_registered`` for the operators added + in this round. Catches the silent regression where the classes tuple + in ``__init__.py`` drops one of them.""" + namespace, _, verb = op.partition(".") + assert hasattr(getattr(bpy.ops, namespace), verb), f"Operator {op!r} is not registered." + + +# --------------------------------------------------------------------------- +# MEPSegmentExtendPreviewDecorator._compute_extend_preview_line — pure helper +# --------------------------------------------------------------------------- + + +def test_extend_preview_line_returns_none_for_degenerate_segment(): + """A zero-length segment has no endpoint to draw from. Pin so a future + refactor doesn't divide-by-zero or render a phantom line at the + object origin.""" + from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator + + result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line( + matrix_world=Matrix.Identity(4), + cursor_world=Vector((0.0, 0.0, 1.0)), + current_length=0.0, + ) + assert result is None + + +def test_extend_preview_line_returns_none_when_cursor_at_current_end(): + """If the cursor projection matches the current segment length exactly, + the extend operator would be a no-op — don't render the line either.""" + from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator + + result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line( + matrix_world=Matrix.Identity(4), + cursor_world=Vector((0.0, 0.0, 1.5)), + current_length=1.5, + ) + assert result is None + + +def test_extend_preview_line_renders_extension_when_cursor_past_end(): + """Happy path: cursor past current end → line runs from current end to + the cursor's projected length. Identity matrix: local-Z maps 1:1 to + world-Z. Pin the endpoints exactly.""" + from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator + + result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line( + matrix_world=Matrix.Identity(4), + cursor_world=Vector((0.0, 0.0, 3.0)), + current_length=1.0, + ) + assert result is not None + start, end = result + assert tuple(start) == pytest.approx((0.0, 0.0, 1.0)) + assert tuple(end) == pytest.approx((0.0, 0.0, 3.0)) + + +def test_extend_preview_line_renders_trim_when_cursor_inside_segment(): + """Cursor inside the segment → line runs from current end BACK to the + projected (shorter) length.""" + from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator + + result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line( + matrix_world=Matrix.Identity(4), + cursor_world=Vector((0.0, 0.0, 0.4)), + current_length=1.0, + ) + assert result is not None + start, end = result + assert tuple(start) == pytest.approx((0.0, 0.0, 1.0)) + assert tuple(end) == pytest.approx((0.0, 0.0, 0.4)) + + +def test_extend_preview_line_follows_raw_projection_behind_segment_origin(): + """When the cursor's projected Z is negative (behind segment origin), + the preview line must follow the raw cursor projection — the user is + pointing somewhere and expects to see where, even though the operator + would floor the actual commit. Matching the operator's clamp would + hide the line whenever the cursor crossed the segment origin.""" + from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator + + result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line( + matrix_world=Matrix.Identity(4), + cursor_world=Vector((0.0, 0.0, -2.0)), + current_length=1.0, + ) + assert result is not None + start, end = result + assert tuple(start) == pytest.approx((0.0, 0.0, 1.0)) + assert tuple(end) == pytest.approx((0.0, 0.0, -2.0)) + + +def test_extend_preview_line_respects_object_rotation(): + """A rotated segment (90° around Y) should produce world-space endpoints + rotated accordingly. Pin so a future refactor doesn't drop the + matrix_world multiplication.""" + import math + + from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator + + rotation = Matrix.Rotation(math.pi / 2, 4, "Y") + result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line( + matrix_world=rotation, + cursor_world=Vector((3.0, 0.0, 0.0)), + current_length=1.0, + ) + assert result is not None + start, end = result + # local (0, 0, 1) rotated by 90° around Y → world (1, 0, 0). + assert tuple(start) == pytest.approx((1.0, 0.0, 0.0), abs=1e-6) + # local (0, 0, 3) rotated by 90° around Y → world (3, 0, 0). + assert tuple(end) == pytest.approx((3.0, 0.0, 0.0), abs=1e-6) + + +# --------------------------------------------------------------------------- +# Lifecycle drift handling — Enable / Finish / Cancel must commit / restore +# matrix_world ↔ IFC ObjectPlacement at the appropriate lifecycle points. +# The AST forward-compat guard pins "a drift hook IS called somewhere"; these +# tests pin "the hook is called in the right branch with the right args." +# --------------------------------------------------------------------------- + + +def _make_segment_context(length=2.0, snap_length=2.0, scale_z=1.0): + """Build (context, props, obj, element) fakes for the MEP edit-lifecycle bases. + The bases access ``self.__class__._predicate`` / ``_props_getter`` so + callers must instantiate a concrete test subclass and call + ``instance._execute(context)`` rather than passing a Mock as ``self``.""" + obj = Mock(name="obj") + obj.scale = Vector((1.0, 1.0, scale_z)) + element = Mock(name="element") + props = Mock(name="props") + props.length = length + props.snap_length = snap_length + props.snap_object_scale_z = scale_z + props.mesh_dirty = False + + context = Mock(name="context") + context.active_object = obj + return context, props, obj, element + + +def _concrete_mep_mixin(props): + """Build a concrete ``_MEPSegmentEditMixin`` subclass that bypasses the + IFC predicate gate and returns the supplied ``props`` from ``_get_props``. + The unified mixin replaced the three-base-class lifecycle pattern; tests now + target the single mixin and override the two ParametricEditMixinBase + hooks instead of class-level ``_predicate`` / ``_props_getter``.""" + from bonsai.bim.module.model.mep import _MEPSegmentEditMixin + + class _ConcreteMEPMixin(_MEPSegmentEditMixin): + @classmethod + def _is_element_type(cls, element): + return True + + @classmethod + def _get_props(cls, obj): + return props + + return _ConcreteMEPMixin + + +def test_enable_pipe_segment_commits_pre_edit_placement_drift(): + """Enable must call ``commit_placement_if_moved(obj, apply_scale=False)`` + BEFORE ``_segment_world_length`` captures ``snap_length``. Without the + commit, snap_length is read from a dragged matrix_world while the IFC + ObjectPlacement is stale — Finish's set_depth would then write + representation coords relative to the wrong origin.""" + context, props, obj, element = _make_segment_context() + cls = _concrete_mep_mixin(props) + + with ( + patch("bonsai.bim.module.model.mep.tool") as mock_tool, + patch("bonsai.bim.parametric_lifecycle.tool", mock_tool), + patch("bonsai.bim.module.model.mep._segment_world_length", return_value=2.0), + ): + mock_tool.Ifc.get_entity.return_value = element + cls()._enable_targets(context) + + mock_tool.Geometry.commit_placement_if_moved.assert_called_once_with(obj, apply_scale=False) + + +def test_finish_pipe_segment_commits_drift_when_no_length_change(): + """Finish without a length change must STILL commit matrix_world drift — + the bug class that motivated this guard. The conditional ``set_depth`` + branch covers the length-changed path transitively; the unconditional + ``commit_placement_if_moved`` after the if/else closes the silent-drop + path.""" + # length == snap_length → no-op session. + context, props, obj, element = _make_segment_context(length=2.0, snap_length=2.0) + cls = _concrete_mep_mixin(props) + + with ( + patch("bonsai.bim.module.model.mep.tool") as mock_tool, + patch("bonsai.bim.parametric_lifecycle.tool", mock_tool), + patch("bonsai.bim.module.model.mep.DumbProfileJoiner") as mock_joiner, + patch("bonsai.bim.module.model.mep._restore_segment_mesh_if_dirty"), + patch("bonsai.bim.module.model.mep._restore_segment_scale_to"), + ): + mock_tool.Ifc.get_entity.return_value = element + cls()._finish_targets(context) + mock_joiner.return_value.set_depth.assert_not_called() # no-length branch + + mock_tool.Geometry.commit_placement_if_moved.assert_called_once_with(obj) + + +def test_cancel_pipe_segment_delegates_to_restore_or_rebaseline(): + """Cancel must call ``tool.Geometry.restore_or_rebaseline_placement`` so + matrix_world reverts in lockstep with the props draft. The helper owns + the is_moved / ObjectPlacement gate.""" + context, props, obj, element = _make_segment_context() + cls = _concrete_mep_mixin(props) + + with ( + patch("bonsai.bim.module.model.mep.tool") as mock_tool, + patch("bonsai.bim.parametric_lifecycle.tool", mock_tool), + patch("bonsai.bim.module.model.mep._restore_segment_mesh_if_dirty"), + ): + mock_tool.Ifc.get_entity.return_value = element + cls()._cancel_targets(context) + + mock_tool.Geometry.restore_or_rebaseline_placement.assert_called_once_with(obj, element) diff --git a/src/bonsai/test/bim/module/model/test_opening_decoration.py b/src/bonsai/test/bim/module/model/test_opening_decoration.py new file mode 100644 index 0000000000..dee8d43299 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_opening_decoration.py @@ -0,0 +1,521 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Tests for tool.Geometry.get_dissolved_edges and the opening-decoration cache +layers. The dissolve helper's contract: + +- Read-only on the input mesh. +- Returns (verts_local, edge_indices) indexed into the dissolved bmesh. +- Material seams survive (delimit=MATERIAL). +- Default angle threshold is 1°.""" + +from math import radians + +import bmesh +import bpy +import pytest +from mathutils import Matrix, Vector + +import bonsai.tool as tool +from bonsai.bim import decorator_cache +from bonsai.bim.module.model import opening as opening_module + +pytestmark = pytest.mark.model + + +@pytest.fixture(autouse=True) +def _reset_decoration_caches(): + # Tests share module-global state (dissolve cache + token, world-draw-data + # cache, batch cache, per-object epochs). Reset every layer so a previous + # test can't poison hit/miss assertions. + decorator_cache.reset_for_test() + opening_module._dissolved_edges_cache.clear() + opening_module._dissolved_edges_cache_token = -1 + opening_module._world_draw_data_cache.clear() + opening_module._batch_cache.clear() + opening_module._object_epochs.clear() + yield + decorator_cache.reset_for_test() + opening_module._dissolved_edges_cache.clear() + opening_module._world_draw_data_cache.clear() + opening_module._batch_cache.clear() + opening_module._object_epochs.clear() + + +def _make_mesh(name: str, verts: list[tuple[float, float, float]], faces: list[tuple[int, ...]]) -> bpy.types.Mesh: + mesh = bpy.data.meshes.new(name) + mesh.from_pydata(verts, [], faces) + mesh.update() + return mesh + + +def _edge_count(mesh: bpy.types.Mesh) -> int: + bm = bmesh.new() + bm.from_mesh(mesh) + n = len(bm.edges) + bm.free() + return n + + +def test_collapses_coplanar_diagonal_on_triangulated_quad(): + # Triangulated unit quad in the XY plane: 4 verts, 2 tris share a diagonal. + # Raw bmesh has 5 edges (4 quad sides + 1 diagonal). Dissolve must drop the + # diagonal because both triangles are perfectly coplanar. + mesh = _make_mesh( + "quad_tri", + verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], + faces=[(0, 1, 2), (0, 2, 3)], + ) + assert _edge_count(mesh) == 5 + + verts, edges = tool.Geometry.get_dissolved_edges(mesh) + + assert len(verts) == 4 + assert len(edges) == 4 + # Every returned edge index must point into the returned verts list. + for a, b in edges: + assert 0 <= a < len(verts) + assert 0 <= b < len(verts) + assert a != b + + +def test_preserves_real_edges_on_cube(): + # Default cube has 8 verts / 12 edges / 6 quad faces. There are no coplanar + # internal splits to dissolve, so the helper must return the cube intact. + mesh = bpy.data.meshes.new("cube") + bm = bmesh.new() + bmesh.ops.create_cube(bm, size=1.0) + bm.to_mesh(mesh) + bm.free() + + verts, edges = tool.Geometry.get_dissolved_edges(mesh) + + assert len(verts) == 8 + assert len(edges) == 12 + + +def test_preserves_material_seam_on_coplanar_split(): + # Two coplanar triangles sharing an edge but each with a different + # material_index. delimit=MATERIAL must keep the shared edge alive. + mesh = _make_mesh( + "split_mat", + verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], + faces=[(0, 1, 2), (0, 2, 3)], + ) + mat_a = bpy.data.materials.new("mat_a") + mat_b = bpy.data.materials.new("mat_b") + mesh.materials.append(mat_a) + mesh.materials.append(mat_b) + mesh.polygons[0].material_index = 0 + mesh.polygons[1].material_index = 1 + mesh.update() + + verts, edges = tool.Geometry.get_dissolved_edges(mesh) + + # The 4 perimeter edges plus the shared diagonal: 5 total survive. + assert len(verts) == 4 + assert len(edges) == 5 + + bpy.data.materials.remove(mat_a) + bpy.data.materials.remove(mat_b) + + +def test_does_not_mutate_input_mesh(): + # The helper must be read-only: viewport draw handlers call it every frame + # and any obj.data mutation would race the depsgraph and trigger redraws. + mesh = _make_mesh( + "ro_quad", + verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], + faces=[(0, 1, 2), (0, 2, 3)], + ) + edges_before = _edge_count(mesh) + verts_before = len(mesh.vertices) + + tool.Geometry.get_dissolved_edges(mesh) + + assert _edge_count(mesh) == edges_before + assert len(mesh.vertices) == verts_before + + +def test_accepts_explicit_angle_limit(): + # Smoke: the angle_limit kwarg must be honored end-to-end (not silently + # ignored). With a near-zero threshold, even sub-degree coplanar splits + # survive; with a generous threshold, they collapse. + mesh = _make_mesh( + "quad_tri", + verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], + faces=[(0, 1, 2), (0, 2, 3)], + ) + + _, edges_zero = tool.Geometry.get_dissolved_edges(mesh, angle_limit=0.0) + _, edges_default = tool.Geometry.get_dissolved_edges(mesh) + + assert len(edges_zero) > len(edges_default), "angle_limit=0 must preserve more edges than the default 1° dissolve" + + +def test_cache_serves_identical_object_on_repeat_call(): + # Without caching, the helper rebuilds verts/edges every viewport redraw. + # Identity (`is`) — not equality — proves the second call hit the cache + # rather than recomputing identical content. + mesh = _make_mesh( + "cached", + verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], + faces=[(0, 1, 2), (0, 2, 3)], + ) + first = opening_module._get_cached_dissolved_edges(mesh) + second = opening_module._get_cached_dissolved_edges(mesh) + + assert first is second + + +def test_cache_invalidates_on_decorator_token_bump(): + # depsgraph_update_post / undo / redo / load all bump the shared decorator + # token; this cache must clear when the token changes so a downstream + # depsgraph edit (mesh content changed) is reflected on the next call. + mesh = _make_mesh( + "bumped", + verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], + faces=[(0, 1, 2), (0, 2, 3)], + ) + first = opening_module._get_cached_dissolved_edges(mesh) + decorator_cache._DECORATOR_CACHE_TOKEN += 1 + second = opening_module._get_cached_dissolved_edges(mesh) + + assert first is not second, "token bump must invalidate the cache entry" + assert len(first[0]) == len(second[0]) + assert len(first[1]) == len(second[1]) + + +def test_cache_partitions_entries_by_mesh_identity(): + # Two distinct meshes share the same epoch; both must coexist in the cache + # so multi-opening frames don't thrash. + mesh_a = _make_mesh( + "a", + verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], + faces=[(0, 1, 2), (0, 2, 3)], + ) + mesh_b = _make_mesh( + "b", + verts=[(0, 0, 0), (2, 0, 0), (2, 2, 0), (0, 2, 0)], + faces=[(0, 1, 2), (0, 2, 3)], + ) + + a_first = opening_module._get_cached_dissolved_edges(mesh_a) + b_first = opening_module._get_cached_dissolved_edges(mesh_b) + a_second = opening_module._get_cached_dissolved_edges(mesh_a) + + assert a_first is a_second, "mesh_a entry must survive an interleaved mesh_b call" + assert a_first is not b_first + + +def test_cache_partitions_entries_by_angle_limit(): + # Same mesh, different angle_limit → different cached results. Hardens + # against a future caller introducing a per-opening threshold override. + mesh = _make_mesh( + "partitioned", + verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], + faces=[(0, 1, 2), (0, 2, 3)], + ) + tight = opening_module._get_cached_dissolved_edges(mesh, angle_limit=0.0) + loose = opening_module._get_cached_dissolved_edges(mesh, angle_limit=radians(1.0)) + tight_again = opening_module._get_cached_dissolved_edges(mesh, angle_limit=0.0) + + assert tight is tight_again + assert tight is not loose + + +# --- world-data cache (_get_cached_world_draw_data) --------------------------- + + +def _make_object(name: str, mesh: bpy.types.Mesh) -> bpy.types.Object: + obj = bpy.data.objects.new(name, mesh) + bpy.context.scene.collection.objects.link(obj) + return obj + + +def _make_triangulated_quad_obj(name: str) -> bpy.types.Object: + mesh = _make_mesh( + name, + verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], + faces=[(0, 1, 2), (0, 2, 3)], + ) + return _make_object(name, mesh) + + +def test_world_data_cache_returns_four_tuple_with_expected_shapes(): + obj = _make_triangulated_quad_obj("shape") + line_verts, verts, edges_indices, tris = opening_module._get_cached_world_draw_data(obj) + + assert len(verts) == 4 # full mesh vert count + assert len(line_verts) == 4 # dissolved (diagonal collapsed → 4 surviving verts) + assert len(edges_indices) == 4 # quad outline, no diagonal + assert len(tris) == 2 # two triangles + assert all(len(t) == 3 for t in tris) + + +def test_world_data_cache_hit_returns_identical_tuple_on_repeat_call(): + obj = _make_triangulated_quad_obj("hit") + first = opening_module._get_cached_world_draw_data(obj) + second = opening_module._get_cached_world_draw_data(obj) + + assert first is second + + +def test_world_data_cache_invalidates_on_object_epoch_bump(): + # depsgraph_update_post bumps per-object epochs (one per Object whose + # transform or geometry changed). After bumping this object's epoch the + # next lookup must miss and recompute. + obj = _make_triangulated_quad_obj("bumped") + first = opening_module._get_cached_world_draw_data(obj) + opening_module._object_epochs[obj.session_uid] = opening_module._object_epochs.get(obj.session_uid, 0) + 1 + second = opening_module._get_cached_world_draw_data(obj) + + assert first is not second + + +def test_world_data_cache_partitions_entries_by_object_identity(): + a = _make_triangulated_quad_obj("a") + b = _make_triangulated_quad_obj("b") + + a_first = opening_module._get_cached_world_draw_data(a) + b_first = opening_module._get_cached_world_draw_data(b) + a_second = opening_module._get_cached_world_draw_data(a) + + assert a_first is a_second + assert a_first is not b_first + + +def test_world_data_cache_reflects_new_matrix_after_epoch_bump(): + # The cache stores world-space verts. A transform without an epoch bump + # would serve stale coordinates — but transform updates bump the object's + # epoch via the depsgraph handler, so after bump + recompute the new + # matrix must be reflected. + obj = _make_triangulated_quad_obj("moved") + before = opening_module._get_cached_world_draw_data(obj) + obj.matrix_world = obj.matrix_world @ Matrix.Translation((5.0, 0.0, 0.0)) + opening_module._object_epochs[obj.session_uid] = opening_module._object_epochs.get(obj.session_uid, 0) + 1 + after = opening_module._get_cached_world_draw_data(obj) + + # Each vert in `after` is 5 units shifted on X relative to `before`. + for a_co, b_co in zip(after[1], before[1]): + assert a_co[0] - b_co[0] == pytest.approx(5.0) + assert a_co[1] == pytest.approx(b_co[1]) + assert a_co[2] == pytest.approx(b_co[2]) + + +def test_world_data_cache_ios_edges_path_returns_curated_edges(): + # When the mesh has an ios_edges attribute, line_verts must equal the full + # verts (no dissolve), and edges_indices must include only entries where + # the attribute is True. + obj = _make_triangulated_quad_obj("curated") + attr = obj.data.attributes.new(name="ios_edges", type="BOOLEAN", domain="EDGE") + # 5 edges total (quad + diagonal). Mark only the 4 quad sides as real. + bm = bmesh.new() + bm.from_mesh(obj.data) + real_edges_count = 0 + for i, edge in enumerate(bm.edges): + is_diagonal = ( + abs(edge.verts[0].co[0] - edge.verts[1].co[0]) > 0 and abs(edge.verts[0].co[1] - edge.verts[1].co[1]) > 0 + ) + attr.data[i].value = not is_diagonal + if not is_diagonal: + real_edges_count += 1 + bm.free() + obj.data.update() + + line_verts, verts, edges_indices, _ = opening_module._get_cached_world_draw_data(obj) + + assert line_verts is verts, "ios_edges path must reuse the full-verts list as line_verts" + assert len(edges_indices) == real_edges_count + + +def test_world_data_cache_dissolve_path_drops_diagonal(): + # Without ios_edges, the cache falls through to dissolve. The 5th edge + # (diagonal) must be gone from edges_indices. + obj = _make_triangulated_quad_obj("dissolved") + line_verts, verts, edges_indices, _ = opening_module._get_cached_world_draw_data(obj) + + assert len(edges_indices) == 4 + assert len(line_verts) == 4 + assert len(verts) == 4 + + +# --- batch cache (_get_cached_batch_or_none / _store_batch_in_cache) --------- + + +def test_batch_cache_returns_none_on_cold_lookup(): + assert opening_module._get_cached_batch_or_none((123, "lines")) is None + + +def test_batch_cache_returns_stored_batch_on_hit(): + # Sentinel stands in for a GPUBatch — the cache treats it opaquely, so + # this test pins lookup/store correctness without needing a real shader. + sentinel = object() + opening_module._store_batch_in_cache((42, "lines"), sentinel) + + assert opening_module._get_cached_batch_or_none((42, "lines")) is sentinel + + +def test_batch_cache_invalidates_on_object_epoch_bump(): + sentinel = object() + opening_module._store_batch_in_cache((42, "lines"), sentinel) + opening_module._object_epochs[42] = opening_module._object_epochs.get(42, 0) + 1 + + assert opening_module._get_cached_batch_or_none((42, "lines")) is None + + +def test_batch_cache_partitions_entries_by_kind(): + # Same object, different batch kinds (LINES vs TRIS vs arrow) coexist — + # required so the same opening's three batches don't evict each other. + lines_batch = object() + tris_batch = object() + opening_module._store_batch_in_cache((42, "lines"), lines_batch) + opening_module._store_batch_in_cache((42, "tris"), tris_batch) + + assert opening_module._get_cached_batch_or_none((42, "lines")) is lines_batch + assert opening_module._get_cached_batch_or_none((42, "tris")) is tris_batch + + +def test_batch_cache_partitions_entries_by_object_uid(): + a_batch = object() + b_batch = object() + opening_module._store_batch_in_cache((1, "lines"), a_batch) + opening_module._store_batch_in_cache((2, "lines"), b_batch) + + assert opening_module._get_cached_batch_or_none((1, "lines")) is a_batch + assert opening_module._get_cached_batch_or_none((2, "lines")) is b_batch + + +# --- per-object epoch invalidation (granularity contract) -------------------- + + +def test_world_data_cache_per_object_epoch_invalidates_only_target(): + # Core contract for the granular-invalidation feature: bumping one object's + # epoch must not evict another object's cached payload. This is what makes + # dragging a single object in a 50-opening scene affordable. + a = _make_triangulated_quad_obj("granular_a") + b = _make_triangulated_quad_obj("granular_b") + + a_first = opening_module._get_cached_world_draw_data(a) + b_first = opening_module._get_cached_world_draw_data(b) + + opening_module._object_epochs[a.session_uid] = opening_module._object_epochs.get(a.session_uid, 0) + 1 + + a_second = opening_module._get_cached_world_draw_data(a) + b_second = opening_module._get_cached_world_draw_data(b) + + assert a_first is not a_second, "a's epoch bump must invalidate a's entry" + assert b_first is b_second, "a's epoch bump must NOT touch b's entry" + + +def test_batch_cache_per_object_epoch_invalidates_only_target(): + a_lines = object() + b_lines = object() + opening_module._store_batch_in_cache((1, "lines"), a_lines) + opening_module._store_batch_in_cache((2, "lines"), b_lines) + + opening_module._object_epochs[1] = opening_module._object_epochs.get(1, 0) + 1 + + assert opening_module._get_cached_batch_or_none((1, "lines")) is None + assert opening_module._get_cached_batch_or_none((2, "lines")) is b_lines + + +def test_global_clear_handler_wipes_everything(): + # undo/redo/load can't be modeled as per-object deltas — the global handler + # must wipe every layer (epochs + both caches) so we can never serve state + # that pre-dates the undo/load. + a = _make_triangulated_quad_obj("wipe_a") + opening_module._get_cached_world_draw_data(a) + opening_module._store_batch_in_cache((a.session_uid, "lines"), object()) + assert a.session_uid in opening_module._world_draw_data_cache + assert (a.session_uid, "lines") in opening_module._batch_cache + + opening_module._clear_decoration_caches_globally() + + assert opening_module._world_draw_data_cache == {} + assert opening_module._batch_cache == {} + assert opening_module._object_epochs == {} + + +class _FakeDepsgraphUpdate: + def __init__(self, id_, transform: bool = False, geometry: bool = False): + self.id = id_ + self.is_updated_transform = transform + self.is_updated_geometry = geometry + + +class _FakeDepsgraph: + def __init__(self, updates): + self.updates = updates + + +def test_depsgraph_handler_bumps_epoch_for_updated_object(): + # Synthesised depsgraph delta: one Object with a transform update. The + # handler must increment that object's epoch. + obj = _make_triangulated_quad_obj("bumped_via_handler") + before = opening_module._object_epochs.get(obj.session_uid, 0) + + deps = _FakeDepsgraph([_FakeDepsgraphUpdate(obj, transform=True)]) + opening_module._bump_object_epochs_for_decoration(None, deps) + + assert opening_module._object_epochs[obj.session_uid] == before + 1 + + +def test_depsgraph_handler_ignores_non_object_updates(): + # Updates whose .id isn't a bpy.types.Object (Mesh, Material, NodeTree…) + # must not affect any object's epoch. + obj = _make_triangulated_quad_obj("untouched") + deps = _FakeDepsgraph([_FakeDepsgraphUpdate(obj.data, geometry=True)]) + opening_module._bump_object_epochs_for_decoration(None, deps) + + assert obj.session_uid not in opening_module._object_epochs + + +def test_depsgraph_handler_ignores_updates_without_transform_or_geometry(): + # An Object update flagged only for shading must not bump the epoch — + # shading changes don't move the wire overlay. + obj = _make_triangulated_quad_obj("shading_only") + deps = _FakeDepsgraph([_FakeDepsgraphUpdate(obj)]) + opening_module._bump_object_epochs_for_decoration(None, deps) + + assert obj.session_uid not in opening_module._object_epochs + + +def test_depsgraph_handler_resolves_cow_original(): + # For non-evaluated Blender objects, obj.original returns obj itself, so + # the .original-resolution path keys the SAME uid the draw handler reads. + # Pinning this prevents a future refactor that drops the .original lookup + # from silently regressing the COW-boundary case (the decorator failing to + # follow a moved object). + obj = _make_triangulated_quad_obj("cow") + deps = _FakeDepsgraph([_FakeDepsgraphUpdate(obj, transform=True)]) + opening_module._bump_object_epochs_for_decoration(None, deps) + + assert obj.original.session_uid in opening_module._object_epochs + + +def test_depsgraph_handler_tolerates_missing_depsgraph(): + # Some Blender event paths may call the handler without a depsgraph; the + # handler must short-circuit instead of raising AttributeError. + opening_module._bump_object_epochs_for_decoration() + opening_module._bump_object_epochs_for_decoration(None) + opening_module._bump_object_epochs_for_decoration(None, None) + + assert opening_module._object_epochs == {} diff --git a/src/bonsai/test/bim/module/model/test_preview_base.py b/src/bonsai/test/bim/module/model/test_preview_base.py new file mode 100644 index 0000000000..e5e6244a69 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_preview_base.py @@ -0,0 +1,221 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Tests for the parametric-edit preview registry contract. + +Every test reads the live ``PREVIEW_CANCEL_OPS`` registry rather than hard- +coding preview keys or cancel-operator names, so adding a new preview to the +registry automatically exercises the same invariants without test changes.""" + +import types + +import bpy +import pytest + +pytestmark = pytest.mark.model + + +def _registry(): + from bonsai.bim.module.model.preview_base import PREVIEW_CANCEL_OPS + + return PREVIEW_CANCEL_OPS + + +def _preview_umbrella(): + return getattr(bpy.context.scene, "BIMPreviewProperties", None) + + +def _registered_previews(): + """``[(attr, op_name, props)]`` for every registry entry that has a real + child PropertyGroup on the umbrella in the current addon build.""" + umbrella = _preview_umbrella() + if umbrella is None: + return [] + out = [] + for attr, op_name in _registry(): + props = getattr(umbrella, attr, None) + if props is not None: + out.append((attr, op_name, props)) + return out + + +class TestRegistryContract: + """Pins the invariant that every entry in PREVIEW_CANCEL_OPS resolves to + a real cancel operator the addon registers. A new preview added to the + registry without its matching cancel operator would otherwise crash + ``try_cancel_active_preview`` on the first Esc.""" + + def test_every_registered_cancel_op_is_callable(self): + for attr, op_name in _registry(): + op = getattr(bpy.ops.bim, op_name, None) + assert op is not None and callable(op), ( + f"Preview '{attr}' in PREVIEW_CANCEL_OPS points to bim.{op_name} " + f"but no such operator is registered." + ) + + +class TestGetPreviewPropsTolerance: + """The bug-class fixed in commit ee63137c6: ``get_preview_props`` is called + from gizmo polls during addon init and from test mocks built on + ``SimpleNamespace`` — neither has a fully-formed Blender context. The + helper must return None rather than raise.""" + + def test_returns_none_when_context_has_no_scene(self): + from bonsai.bim.module.model.preview_base import get_preview_props + + # Pass an arbitrary attr name — the contract is the same for every + # preview key, so picking one literally would be a maintenance trap. + for attr, _ in _registry(): + assert get_preview_props(types.SimpleNamespace(), attr) is None + break + + def test_returns_none_when_scene_lacks_umbrella(self): + from bonsai.bim.module.model.preview_base import get_preview_props + + ctx = types.SimpleNamespace(scene=types.SimpleNamespace()) + for attr, _ in _registry(): + assert get_preview_props(ctx, attr) is None + break + + +class TestActivationCycle: + """End-to-end contract on the real addon: each registered preview can be + activated and then cancelled to inactive. Runs for every preview that + has a wired PropertyGroup, so a new preview added to the registry + + umbrella is covered without test edits.""" + + def test_any_preview_active_reflects_each_preview_state(self): + from bonsai.bim.module.model.preview_base import any_preview_active + + registered = _registered_previews() + if not registered: + pytest.skip("No previews wired in this build — registry-only entries") + + # All inactive baseline. + for _, _, props in registered: + props.is_active = False + assert any_preview_active(bpy.context) is False + + # Flip each one independently — the helper must report True. + for _, _, props in registered: + props.is_active = True + assert any_preview_active(bpy.context) is True + props.is_active = False + + def test_discard_pending_previews_clears_every_active_flag(self): + from bonsai.bim.module.model.preview_base import discard_pending_previews + + registered = _registered_previews() + if not registered: + pytest.skip("No previews wired in this build — registry-only entries") + + for _, _, props in registered: + props.is_active = True + discard_pending_previews(bpy.context.scene) + for attr, _, props in registered: + assert props.is_active is False, f"discard_pending_previews left '{attr}' active" + + +class TestClearPreviewState: + """``clear_preview_state`` is the shared cleanup routine every preview + operator calls on commit / cancel. The contract is: ``is_active`` flips + to False, every ``*_id`` IntProperty zeroes, everything else stays.""" + + def test_clears_is_active_and_id_fields_on_real_property_groups(self): + from bonsai.bim.module.model.preview_base import clear_preview_state + + registered = _registered_previews() + if not registered: + pytest.skip("No previews wired in this build — registry-only entries") + + for attr, _, props in registered: + # Seed every *_id IntProperty with a non-zero sentinel and flip + # the activity flag so the helper has something to clear. + id_fields = [ + name for name, rna in props.bl_rna.properties.items() if name.endswith("_id") and rna.type == "INT" + ] + assert id_fields, f"Preview '{attr}' has no *_id IntProperty — registry shape changed" + for name in id_fields: + setattr(props, name, 42) + props.is_active = True + + clear_preview_state(props) + + assert props.is_active is False, f"Preview '{attr}' is_active not cleared" + for name in id_fields: + assert getattr(props, name) == 0, f"Preview '{attr}' field '{name}' not zeroed" + + def test_leaves_non_id_fields_untouched(self): + """Non-``*_id`` fields (FloatProperty params like ``radius``, + ``start_length``) must survive the reset — they re-seed on the next + enable, so untouching them here avoids a redundant write.""" + from bonsai.bim.module.model.preview_base import clear_preview_state + + bend = getattr(_preview_umbrella(), "bend", None) + if bend is None: + pytest.skip("Bend preview not wired in this build") + + bend.is_active = True + bend.start_length = 0.42 + bend.radius = 0.99 + clear_preview_state(bend) + + assert bend.is_active is False + assert bend.start_length == pytest.approx(0.42) + assert bend.radius == pytest.approx(0.99) + + +class TestSaveOnDiscardWired: + """Pins that the SaveProject operator clears preview state before writing + the IFC file — a stuck is_active flag persisted through the save would + silently hide sister gizmos on the next file load. + + Structural check: the SaveProject operator class must reference the + discard helper somewhere in its execute path. Behavioural integration + (actually saving a .blend with an active preview and reloading) belongs + in the bim feature suite; this is the small guard against accidental + removal of the call site.""" + + def test_save_project_dispatches_discard_pending_previews(self): + import inspect + + from bonsai.bim.module.model import preview_base + from bonsai.bim.module.project import operator as project_operator + + # Find the project save operator dynamically — looking for any + # Operator class whose bl_idname is "bim.save_project". Avoids + # hard-coding the class identifier. + save_op = None + for name in dir(project_operator): + obj = getattr(project_operator, name) + if isinstance(obj, type) and getattr(obj, "bl_idname", None) == "bim.save_project": + save_op = obj + break + assert save_op is not None, "Expected an operator with bl_idname='bim.save_project' in project/operator.py" + + # Walk the class's methods for the discard call. Avoids pinning a + # specific method name (_execute vs execute vs an inner helper) so + # the test survives operator refactors. + source = inspect.getsource(save_op) + assert preview_base.discard_pending_previews.__name__ in source, ( + f"{save_op.__name__} does not reference discard_pending_previews. " + "Saving with a preview open would persist its is_active flag to the " + ".blend file and silently hide sister gizmos on reopen." + ) diff --git a/src/bonsai/test/bim/module/model/test_roof_gizmos.py b/src/bonsai/test/bim/module/model/test_roof_gizmos.py new file mode 100644 index 0000000000..1d7e1c48a1 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_roof_gizmos.py @@ -0,0 +1,306 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Unit tests for the roof parametric gizmo group. + +Covers the parts of ``GizmoRoofEdition`` that don't need a live Blender +viewport: the mode-conditional ``visibility_condition`` lambdas, the +slope ``compute_value`` / ``apply_value`` roundtrip, the +``CycleRoofGenerationMethod`` operator metadata + cycle behaviour, and +the ``_update_dimension_gizmo_positions`` override that anchors all three +dimension gizmos at the object's local origin.""" + +import math +from types import SimpleNamespace +from unittest.mock import patch + +import bpy +import pytest + +pytestmark = pytest.mark.model + + +def _get_config(attr_name): + """Return the ``DimensionGizmoConfig`` for ``attr_name`` from the roof gizmo.""" + from bonsai.bim.module.model.roof import GizmoRoofEdition + + for cfg in GizmoRoofEdition.dimension_gizmo_props: + if cfg.attr_name == attr_name: + return cfg + raise AssertionError(f"no DimensionGizmoConfig with attr_name={attr_name!r}") + + +# ---------------------------------------------------------------------------- +# Mode-conditional visibility +# ---------------------------------------------------------------------------- +# +# ``height`` and ``angle`` are mutually exclusive — exactly one is shown +# depending on ``generation_method``. ``roof_thickness`` applies regardless +# of the generation mode. + + +def test_height_gizmo_visible_only_in_height_mode(): + cfg = _get_config("height") + assert cfg.visibility_condition(SimpleNamespace(generation_method="HEIGHT")) is True + assert cfg.visibility_condition(SimpleNamespace(generation_method="ANGLE")) is False + + +def test_angle_gizmo_visible_only_in_angle_mode(): + cfg = _get_config("angle") + assert cfg.visibility_condition(SimpleNamespace(generation_method="ANGLE")) is True + assert cfg.visibility_condition(SimpleNamespace(generation_method="HEIGHT")) is False + + +def test_thickness_has_no_mode_gate(): + """Slab thickness applies to both generation modes — pinning + ``visibility_condition is None`` guards against an accidental mode-gate + being added later that would silently hide it when toggling modes.""" + assert _get_config("roof_thickness").visibility_condition is None + + +# ---------------------------------------------------------------------------- +# Slope (angle) ↔ rise roundtrip +# ---------------------------------------------------------------------------- +# +# The slope handle displays vertical rise at a fixed 1m run; dragging it +# updates ``props.angle`` via ``atan2(rise, run)``. Roundtrip preservation +# is the contract — feeding ``compute_value`` into ``apply_value`` must +# leave the angle unchanged (within float tolerance). + + +def test_slope_compute_value_returns_rise_at_reference_run(): + from bonsai.bim.module.model.roof import _ROOF_SLOPE_REFERENCE_RUN + + cfg = _get_config("angle") + # 30° slope → rise = tan(30°) * 1m ≈ 0.5774 m + props = SimpleNamespace(angle=math.radians(30)) + assert cfg.compute_value(props) == pytest.approx(math.tan(math.radians(30)) * _ROOF_SLOPE_REFERENCE_RUN) + + +def test_slope_apply_value_sets_angle_from_rise(): + from bonsai.bim.module.model.roof import _ROOF_SLOPE_REFERENCE_RUN + + cfg = _get_config("angle") + props = SimpleNamespace(angle=0.0) + cfg.apply_value(props, 0.5) + assert props.angle == pytest.approx(math.atan2(0.5, _ROOF_SLOPE_REFERENCE_RUN)) + + +def test_slope_roundtrip_preserves_angle(): + cfg = _get_config("angle") + for deg in (5, 15, 30, 45, 60, 80): + props = SimpleNamespace(angle=math.radians(deg)) + rise = cfg.compute_value(props) + cfg.apply_value(props, rise) + assert math.degrees(props.angle) == pytest.approx(deg, abs=1e-6) + + +def test_slope_apply_value_clamps_negative_to_zero(): + """A negative drag (rise < 0) must not produce a negative angle — + ``atan2(-x, run)`` would yield a negative result, but ``apply_value`` + clamps to ``[0, pi/2 - 1e-3]`` so the roof never inverts.""" + cfg = _get_config("angle") + props = SimpleNamespace(angle=math.radians(30)) + cfg.apply_value(props, -1.0) + assert props.angle == 0.0 + + +def test_slope_apply_value_clamps_at_near_vertical(): + """Slopes approaching 90° are clamped just below to avoid a vertical + extrusion that would degenerate the bisect step in + ``generate_hipped_roof_bmesh``.""" + from bonsai.bim.module.model.roof import _ROOF_MAX_SLOPE_ANGLE + + cfg = _get_config("angle") + props = SimpleNamespace(angle=0.0) + cfg.apply_value(props, 1e9) # absurdly steep + assert props.angle == pytest.approx(_ROOF_MAX_SLOPE_ANGLE) + + +# ---------------------------------------------------------------------------- +# Cycle operator metadata +# ---------------------------------------------------------------------------- +# +# ``CycleRoofGenerationMethod`` plugs into ``CycleTypeMixin`` so the +# HEIGHT ↔ ANGLE icon cycles through the two values. The mixin reads four +# class attributes to do its work; if any drift, the cycle no-ops or +# CANCELLED-loops in subtle ways. Pin them here. + + +def test_cycle_operator_class_metadata(): + from typing import get_args + + from bonsai import tool + from bonsai.bim.module.model.roof import CycleRoofGenerationMethod + + assert CycleRoofGenerationMethod.bl_idname == "bim.cycle_roof_generation_method" + assert CycleRoofGenerationMethod.element_checker == tool.Parametric.is_roof + assert CycleRoofGenerationMethod.props_getter == tool.Model.get_roof_props + assert CycleRoofGenerationMethod.type_attr == "generation_method" + # The Literal resolves to ("HEIGHT", "ANGLE") — the mixin calls + # ``get_args(type_literal)`` to enumerate the cycle. + assert get_args(CycleRoofGenerationMethod.type_literal) == ("HEIGHT", "ANGLE") + assert CycleRoofGenerationMethod.type_literal is tool.Model.RoofGenerationMethod + + +def test_cycle_operator_wired_on_gizmo_group(): + """The gizmo group's ``cycle_type_operator`` must match the bl_idname or + the base class skips the cycle icon entirely (see gizmos.py:4987).""" + from bonsai.bim.module.model.roof import CycleRoofGenerationMethod, GizmoRoofEdition + + assert GizmoRoofEdition.cycle_type_operator == CycleRoofGenerationMethod.bl_idname + + +def _cycle_stub_self(*, reverse: bool, props, element_is_target: bool = True): + """Build a stub ``self`` for ``CycleTypeMixin._cycle_type``. + + ``bpy.types.Operator`` subclasses can't be ``__init__``-ed outside of + Blender's registration path (``bpy_struct.__new__`` rejects a bare + call). Calling the unbound mixin method with a stub ``self`` that + mirrors the class attributes the method reads is the cleanest way to + exercise the cycle logic without launching a registered operator + instance. + + ``element_checker`` and ``props_getter`` are captured by the cycle + operator at class-definition time, so global ``tool.*`` patches at + test time can't intercept them — the stub injects callables directly + instead. ``_resolve_target`` is bound from ``TypeAccessorBase`` so + the cycle method's call into it dispatches against the stub + attributes.""" + from types import MethodType + + from bonsai.bim.module.model.roof import CycleRoofGenerationMethod + from bonsai.bim.parametric_lifecycle import TypeAccessorBase + + stub = SimpleNamespace( + reverse=reverse, + skip_element_check=False, + element_checker=lambda _elem: element_is_target, + props_getter=lambda _obj: props, + type_literal=CycleRoofGenerationMethod.type_literal, + type_attr=CycleRoofGenerationMethod.type_attr, + ) + stub._resolve_target = MethodType(TypeAccessorBase._resolve_target, stub) + return stub + + +def test_cycle_type_advances_forward(): + """``_cycle_type`` advances the prop value to the next item in the + Literal. The stub injects ``element_checker`` / ``props_getter`` + directly so the method runs without a live IFC fixture.""" + from bonsai import tool + from bonsai.bim import parametric_lifecycle as gizmo_module + + props = SimpleNamespace(generation_method="HEIGHT") + context = SimpleNamespace(active_object=object()) + + with patch.object(tool.Ifc, "get_entity", return_value=object()): + result = gizmo_module.CycleTypeMixin._cycle_type(_cycle_stub_self(reverse=False, props=props), context) + assert result == {"FINISHED"} + assert props.generation_method == "ANGLE" + + +def test_cycle_type_reverse_walks_backward(): + """Shift+click sets ``reverse=True`` and walks the cycle in the other + direction — from HEIGHT that means wrapping to ANGLE (the last item).""" + from bonsai import tool + from bonsai.bim import parametric_lifecycle as gizmo_module + + props = SimpleNamespace(generation_method="HEIGHT") + context = SimpleNamespace(active_object=object()) + + with patch.object(tool.Ifc, "get_entity", return_value=object()): + gizmo_module.CycleTypeMixin._cycle_type(_cycle_stub_self(reverse=True, props=props), context) + assert props.generation_method == "ANGLE" # wrapped from HEIGHT backward + + +def test_cycle_type_cancels_when_active_is_not_a_roof(): + """Non-roof active object → CANCELLED, props untouched. Guards against + a stray cycle click on a wall mutating ``wall.generation_method`` (a + non-existent attr) and silently no-oping or AttributeError-ing later.""" + from bonsai import tool + from bonsai.bim import parametric_lifecycle as gizmo_module + + props = SimpleNamespace(generation_method="HEIGHT") + context = SimpleNamespace(active_object=object()) + + with patch.object(tool.Ifc, "get_entity", return_value=object()): + result = gizmo_module.CycleTypeMixin._cycle_type( + _cycle_stub_self(reverse=False, props=props, element_is_target=False), + context, + ) + assert result == {"CANCELLED"} + assert props.generation_method == "HEIGHT" + + +# ---------------------------------------------------------------------------- +# _update_dimension_gizmo_positions — origin anchoring +# ---------------------------------------------------------------------------- +# +# All three dimension gizmos anchor at the object's local origin. Their +# declared axes (height/slope +Z, thickness -Z) separate them in 3D so +# they don't visually collide despite sharing a position; height + slope +# are themselves mutually exclusive via visibility_condition on +# generation_method. + + +def test_override_positions_all_dimensions_at_object_origin(): + """The override calls ``set_dimension_gizmo_position`` with the + object-local origin (0, 0, 0) for every dimension gizmo. Anchoring at + the object origin keeps the gizmos tied to the object's matrix_world + rather than to footprint geometry that may not be cached yet — fixes + the first-click default-identity-matrix bug structurally.""" + from bonsai.bim.module.model.roof import GizmoRoofEdition + + calls: dict[str, tuple] = {} + + def record(attr_name, _mw, position, axis, _value=None): + calls[attr_name] = (position, axis) + + stub = SimpleNamespace(set_dimension_gizmo_position=record) + GizmoRoofEdition._update_dimension_gizmo_positions(stub, context=None, mw=None, props=None) + + assert set(calls) == {"height", "angle", "roof_thickness"} + for name in ("height", "angle", "roof_thickness"): + position, _axis = calls[name] + assert position.xyz[:] == pytest.approx( + (0.0, 0.0, 0.0) + ), f"{name} anchored at {position.xyz[:]} instead of object origin" + # Axes split the three handles along Z+ (height/slope) vs Z- (thickness) + # so they don't visually collide despite sharing the anchor point. + assert calls["height"][1] == (0, 0, 1) + assert calls["angle"][1] == (0, 0, 1) + assert calls["roof_thickness"][1] == (0, 0, -1) + + +# ---------------------------------------------------------------------------- +# Registration smoke test +# ---------------------------------------------------------------------------- +# +# Pattern 4 from _shared/bonsai-test-patterns.md: assert the operator is +# actually registered as ``bim.cycle_roof_generation_method``. Catches +# ``bl_idname`` typos and missing-from-``classes``-tuple regressions at +# test time rather than at user-click time (the failure mode otherwise is +# a silent no-op on the cycle icon, because the gizmo base class skips the +# icon entirely if its ``cycle_type_operator`` resolves to nothing). + + +def test_cycle_operator_is_registered_under_bim_namespace(): + assert hasattr(bpy.ops.bim, "cycle_roof_generation_method") diff --git a/src/bonsai/test/bim/module/model/test_stair_gizmos.py b/src/bonsai/test/bim/module/model/test_stair_gizmos.py new file mode 100644 index 0000000000..263d5cc6e2 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_stair_gizmos.py @@ -0,0 +1,201 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Regression guard for the stair icon billboard fix. + +Before the fix, ``set_icon_gizmo_position`` in ``bim.module.drawing.gizmos`` +composed ``mw @ (Translation @ billboard_rot @ Scale)``, which applied the +stair's world rotation on top of the billboard rotation. The result was +icons (validate / cancel / lock / +/- / cycle / tread_lock) drawn edge-on +to the camera for any stair rotated in plan — effectively unclickable. + +The fix routes through ``billboarded_at(world_pos, billboard_rot, scale)``, +which computes ``Translation(world_pos) @ billboard_rot @ Scale`` — the +object's rotation is folded into the translation only, never the rotation.""" + +import math +import types + +import bpy +import pytest + +pytestmark = pytest.mark.model + + +def _rotation_close(a, b, tol: float = 1e-6) -> bool: + for row_a, row_b in zip(a, b): + for va, vb in zip(row_a, row_b): + if abs(va - vb) > tol: + return False + return True + + +@pytest.mark.parametrize("angle_deg", [0, 30, 45, 90, 135, 217]) +def test_billboarded_at_rotation_is_pure_billboard(angle_deg): + """Object rotation must not leak into the gizmo's rotation part.""" + from mathutils import Matrix, Vector + + from bonsai.bim.module.drawing.gizmos import billboarded_at + + mw = Matrix.Rotation(math.radians(angle_deg), 4, "Z") @ Matrix.Translation((3, 4, 5)) + billboard_rot = Matrix.Rotation(math.radians(30), 4, "X") + + world_pos = mw @ Vector((1, 0, 2)) + result = billboarded_at(world_pos, billboard_rot, scale=0.5) + + # The rotation part of result, after stripping the 0.5 uniform scale, + # must equal billboard_rot — no contribution from mw's rotation. + rotation_part = result.to_3x3() * 2.0 + assert _rotation_close(rotation_part.to_4x4(), billboard_rot) + + +def test_billboarded_at_translation_is_world_pos(): + """Translation lands exactly at the world-space target.""" + from mathutils import Matrix, Vector + + from bonsai.bim.module.drawing.gizmos import billboarded_at + + world_pos = Vector((1.23, 4.56, 7.89)) + result = billboarded_at(world_pos, Matrix.Identity(4), scale=0.5) + assert (result.translation - world_pos).length < 1e-6 + + +def test_set_icon_gizmo_position_does_not_apply_object_rotation(): + """End-to-end: the helper used by every stair icon (and shared with all + parametric gizmo groups) must produce a matrix whose rotation part is + billboard_rot, not mw_rotation @ billboard_rot. This is the exact bug + that left stair icons edge-on to the camera.""" + from mathutils import Matrix, Vector + + from bonsai.bim.module.drawing.gizmos import ( + BaseParametricGizmoGroup, + billboarded_at, + ) + + # Same inputs as the real call site (stair.py:747-765), but we drive the + # helper directly so we don't need a registered GizmoGroup. We bind a + # stand-in `get_gizmo_if_visible` that returns a tiny mock; the helper's + # observable output is the matrix_basis it assigns. + captured = {} + + class _GizmoStub: + matrix_basis = Matrix.Identity(4) + + stub = _GizmoStub() + + def _fake_get(name): + captured["name"] = name + return stub + + # Bind the helper to a throwaway instance so `self.get_gizmo_if_visible` + # resolves to our stub without registering a real GizmoGroup with Blender. + fake_self = types.SimpleNamespace(get_gizmo_if_visible=_fake_get) + mw = Matrix.Rotation(math.radians(45), 4, "Z") @ Matrix.Translation((3, 4, 5)) + billboard_rot = Matrix.Rotation(math.radians(30), 4, "X") + local_pos = Vector((1, 0, 2)) + BaseParametricGizmoGroup.set_icon_gizmo_position( + fake_self, + "validate_gizmo", + mw=mw, + x=local_pos.x, + y=local_pos.y, + z=local_pos.z, + billboard_rot=billboard_rot, + scale=0.5, + ) + + expected = billboarded_at(mw @ local_pos, billboard_rot, 0.5) + + assert captured["name"] == "validate_gizmo" + for row_a, row_b in zip(stub.matrix_basis, expected): + for va, vb in zip(row_a, row_b): + assert abs(va - vb) < 1e-6 + + +def test_icon_slot_placeholder_skips_validation_and_returns_no_attrs(): + """Placeholder slots reserve an X position without an auto-created gizmo: + construction must not require ``gizmo_idname`` / ``operator``, and + ``gizmo_attrs()`` must return an empty tuple so the base class's + setup/positioning loops naturally skip the slot.""" + from bonsai.bim.module.drawing.gizmos import IconSlot + + slot = IconSlot(name="my_label", placeholder=True) + assert slot.placeholder is True + assert slot.gizmo_attrs() == () + + with pytest.raises(TypeError, match="gizmo_idname"): + IconSlot(name="broken") + + +def test_count_label_gizmo_is_registered(): + """The shared text-only ``xN`` gizmo must register so the stair group's + ``gizmos.new("BIM_GT_count_label")`` resolves.""" + from bonsai.bim.module.drawing.gizmos import GizmoCountLabel + + assert GizmoCountLabel.bl_idname == "BIM_GT_count_label" + assert bpy.types.Gizmo.bl_rna_get_subclass_py("BIM_GT_count_label") is GizmoCountLabel + + +def test_stair_edit_row_reserves_label_slot_between_tread_lock_and_plus(): + """The ``tread_count_label`` placeholder slot must sit one + ``ICON_ARRAY_GAP`` past the tread-lock and one gap before the plus + icon, so the layout naturally allocates the count label's X without + any subclass-side gap math.""" + from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup + from bonsai.bim.module.model.stair import GizmoStairEdition + + slot_x = GizmoStairEdition._slot_x_positions() + gap = BaseParametricGizmoGroup.ICON_ARRAY_GAP + + assert "tread_count_label" in slot_x + assert slot_x["tread_count_label"] - slot_x["tread_lock"] == pytest.approx(gap) + assert slot_x["plus"] - slot_x["tread_count_label"] == pytest.approx(gap) + assert slot_x["minus"] - slot_x["plus"] == pytest.approx(gap) + + +def test_update_tread_count_gizmos_toggles_label_with_editing(): + """``update_tread_count_gizmos`` must propagate ``props.is_editing`` + to the label's hide state so the badge appears only inside edit mode.""" + from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup + from bonsai.bim.module.model.stair import GizmoStairEdition + + class _GizmoStub: + def __init__(self): + self.hide = False + + plus_gz = _GizmoStub() + minus_gz = _GizmoStub() + label_gz = _GizmoStub() + + fake_self = types.SimpleNamespace( + plus_gizmo=plus_gz, + minus_gizmo=minus_gz, + tread_count_label_gizmo=label_gz, + update_gizmo_visibility=lambda g, v: BaseParametricGizmoGroup.update_gizmo_visibility(fake_self, g, v), + is_gizmo_hidden_by_modal=lambda g: False, + ) + + props_editing = types.SimpleNamespace(is_editing=True, number_of_treads=5) + GizmoStairEdition.update_tread_count_gizmos(fake_self, props_editing) + assert label_gz.hide is False + + props_idle = types.SimpleNamespace(is_editing=False, number_of_treads=5) + GizmoStairEdition.update_tread_count_gizmos(fake_self, props_idle) + assert label_gz.hide is True diff --git a/src/bonsai/test/bim/module/model/test_transform_modal_gate.py b/src/bonsai/test/bim/module/model/test_transform_modal_gate.py new file mode 100644 index 0000000000..139af4cdc0 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_transform_modal_gate.py @@ -0,0 +1,232 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Behaviour contract: every parametric gizmo group hides while a Blender +transform modal (G/R/S and siblings) is dragging ``matrix_world``. + +Discovery walks each parametric-edit module rather than naming gizmo groups — +adding a new group automatically joins the test. The test exercises the +BEHAVIOUR (poll returns False / draw_prepare early-returns when a transform +modal is active) without pinning the name of the helper used internally.""" + +import importlib +from unittest.mock import MagicMock, patch + +import bpy +import pytest + +pytestmark = pytest.mark.model + +PARAMETRIC_MODULES = ( + "bonsai.bim.module.model.array", + "bonsai.bim.module.model.door", + "bonsai.bim.module.model.host_add_opening_gizmo", + "bonsai.bim.module.model.roof", + "bonsai.bim.module.model.stair", + "bonsai.bim.module.model.wall", + "bonsai.bim.module.model.window", +) + + +def _discover_parametric_gizmo_groups(): + """Walk each parametric-edit module for ``bpy.types.GizmoGroup`` subclasses + defined locally. Preview-owning gizmo groups (bl_idname contains 'preview') + are excluded from the poll-level test: their poll legitimately fires while + the preview is active, and the transform-modal hide for them lives in + ``draw_prepare`` via ``BillboardingGizmoGroupMixin``.""" + out = [] + for mod_path in PARAMETRIC_MODULES: + mod = importlib.import_module(mod_path) + for name in dir(mod): + obj = getattr(mod, name) + if not isinstance(obj, type): + continue + if not issubclass(obj, bpy.types.GizmoGroup) or obj is bpy.types.GizmoGroup: + continue + if obj.__module__ != mod.__name__: + continue + bl_idname = (getattr(obj, "bl_idname", "") or "").lower() + if "preview" in bl_idname: + continue + out.append((f"{mod_path.rsplit('.', 1)[-1]}.{name}", obj)) + return out + + +class TestDiscoveryFindsParametricGizmoGroups: + def test_at_least_one_group_per_canonical_module(self): + """If discovery returns zero groups for a module the walk has drifted — + likely the gizmo group moved to a different file. Surface the drift + with the module name in the diagnostic.""" + per_module: dict[str, int] = {} + for fq_name, _cls in _discover_parametric_gizmo_groups(): + mod_short = fq_name.split(".", 1)[0] + per_module[mod_short] = per_module.get(mod_short, 0) + 1 + empty = [m.rsplit(".", 1)[-1] for m in PARAMETRIC_MODULES if per_module.get(m.rsplit(".", 1)[-1], 0) == 0] + assert not empty, ( + f"Parametric modules with zero GizmoGroup subclasses (discovery walk drifted?): {empty}. " + "Update PARAMETRIC_MODULES or check whether the gizmo groups moved to a new file." + ) + + +class TestParametricGizmoPollsHideDuringTransformModal: + """For each discovered parametric gizmo group, mock the transform-modal + detector to True and call ``poll(bpy.context)``. Every poll must return + False — any True is a poll that wouldn't hide during a G/R/S drag, leaving + the gizmos jittering against the dragging matrix.""" + + def test_every_group_poll_returns_false_when_transform_modal_active(self): + groups = _discover_parametric_gizmo_groups() + offenders = [] + with patch( + "bonsai.bim.module.drawing.gizmos._is_transform_modal_active", + return_value=True, + ): + for name, cls in groups: + poll = getattr(cls, "poll", None) + if poll is None: + continue + try: + result = poll(bpy.context) + except Exception as exc: # noqa: BLE001 + offenders.append((name, f"poll raised: {type(exc).__name__}: {exc}")) + continue + if result: + offenders.append((name, "poll returned True with transform modal active")) + + assert not offenders, ( + "Parametric gizmo polls that don't gate on the transform-modal detector " + "(or raise instead of returning False): " + + ", ".join(f"{n} — {why}" for n, why in offenders) + + ". Hide parametric gizmos while Blender's transform modal is dragging " + "matrix_world so they don't jitter off-cursor. The conventional path is to " + "early-return from poll when _is_transform_modal_active(context) is True." + ) + + +class TestBaseParametricPollHidesDuringTransformModal: + """Cross-feature base poll: door / window / stair / roof / railing / array + all inherit ``BaseParametricGizmoGroup``. Its poll must short-circuit on + the transform-modal detector so every inheriting feature behaves uniformly.""" + + def test_base_parametric_poll_returns_false(self): + from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup + + with patch("bonsai.tool.Blender.get_active_object", return_value=object()): + with patch("bonsai.tool.Blender.are_viewport_gizmos_enabled", return_value=True): + with patch( + "bonsai.bim.module.model.preview_base.any_preview_active", + return_value=False, + ): + with patch( + "bonsai.bim.module.drawing.gizmos._is_transform_modal_active", + return_value=True, + ): + assert BaseParametricGizmoGroup.poll(bpy.context) is False + + +class TestBaseIconActionPollHidesDuringTransformModal: + """``BaseIconActionGroup`` is the parent of the simple icon-row gizmo + groups; its poll mirrors the base parametric gate for forward-compat + symmetry. Pinning here ensures a future icon-row group authored via this + base inherits the transform-modal hide for free.""" + + def test_base_icon_action_poll_returns_false(self): + from bonsai.bim.module.drawing.gizmos import BaseIconActionGroup + + with patch("bonsai.tool.Blender.get_active_object", return_value=object()): + with patch("bonsai.tool.Blender.are_viewport_gizmos_enabled", return_value=True): + with patch( + "bonsai.bim.module.drawing.gizmos._is_transform_modal_active", + return_value=True, + ): + assert BaseIconActionGroup.poll(bpy.context) is False + + +class TestHelperReadsWindowModalOperators: + """Pin the public contract of ``_is_transform_modal_active``: it reads + ``context.window.modal_operators`` (Blender 4.2+) and returns True iff any + operator's ``bl_idname`` starts with ``TRANSFORM_OT_``. The check itself + is dependency-free and worth pinning so a future refactor that swaps the + detection mechanism either keeps the contract or updates the test.""" + + def test_returns_true_for_transform_translate(self): + from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active + + fake_op = MagicMock() + fake_op.bl_idname = "TRANSFORM_OT_translate" + fake_context = MagicMock() + fake_context.window.modal_operators = [fake_op] + assert _is_transform_modal_active(fake_context) is True + + def test_returns_true_for_transform_rotate_and_resize(self): + from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active + + for idname in ("TRANSFORM_OT_rotate", "TRANSFORM_OT_resize", "TRANSFORM_OT_shear"): + fake_op = MagicMock() + fake_op.bl_idname = idname + fake_context = MagicMock() + fake_context.window.modal_operators = [fake_op] + assert _is_transform_modal_active(fake_context) is True, f"missed {idname}" + + def test_returns_false_for_non_transform_modal(self): + from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active + + fake_op = MagicMock() + fake_op.bl_idname = "VIEW3D_OT_select_box" + fake_context = MagicMock() + fake_context.window.modal_operators = [fake_op] + assert _is_transform_modal_active(fake_context) is False + + def test_returns_true_for_bonsai_move_macro(self): + """Bonsai overrides the G key with a macro that wraps + ``TRANSFORM_OT_translate``. While the macro is the outer modal entry + the inner transform does not surface in ``modal_operators``; matching + the macro idname covers the gap. Note Blender exposes ``bl_idname`` + at runtime in the ``BIM_OT_`` form, not the ``bim.`` + form used in the class declaration — verified via real-Blender modal + introspection during grab.""" + from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active + + macros = ( + "BIM_OT_override_move_macro", + "BIM_OT_override_object_duplicate_move_macro", + "BIM_OT_override_object_duplicate_move_linked_macro", + "BIM_OT_object_duplicate_move_linked_aggregate_macro", + ) + for idname in macros: + fake_op = MagicMock() + fake_op.bl_idname = idname + fake_context = MagicMock() + fake_context.window.modal_operators = [fake_op] + assert _is_transform_modal_active(fake_context) is True, f"missed {idname}" + + def test_returns_false_for_empty_modal_stack(self): + from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active + + fake_context = MagicMock() + fake_context.window.modal_operators = [] + assert _is_transform_modal_active(fake_context) is False + + def test_returns_false_when_window_is_none(self): + from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active + + fake_context = MagicMock() + fake_context.window = None + assert _is_transform_modal_active(fake_context) is False diff --git a/src/bonsai/test/bim/module/model/test_undo_resync_parametric_drafts.py b/src/bonsai/test/bim/module/model/test_undo_resync_parametric_drafts.py new file mode 100644 index 0000000000..9bf7c8c33a --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_undo_resync_parametric_drafts.py @@ -0,0 +1,106 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Tests for ``parametric_lifecycle.resync_parametric_drafts_after_undo``. + +Blender's undo restores PropertyGroup field values but does not refire +their ``update`` callbacks, so the preview mesh of an in-progress +parametric draft desyncs from the gizmo dimension widget after Ctrl+Z. +The resync helper walks active drafts and re-runs the per-type +regenerator to bring preview back in line with the (restored) draft +state. This file pins the dispatch contract.""" + +from unittest.mock import MagicMock, patch + +import bpy +import pytest + +import bonsai.tool as tool +from bonsai.bim import parametric_lifecycle + +pytestmark = pytest.mark.model + + +def test_undo_regenerators_target_registered_parametric_types(): + """Every entry in ``UNDO_REGENERATORS`` must name a real parametric + type. A typo would silently no-op on Ctrl+Z, restoring the desync + this helper is meant to prevent.""" + registered_names = {f.name for f in tool.Parametric.EDIT_TYPES} + unknown = set(parametric_lifecycle.UNDO_REGENERATORS) - registered_names + assert not unknown, f"UNDO_REGENERATORS keys {unknown} are not in tool.Parametric.EDIT_TYPES" + + +def test_resync_skips_objects_not_in_parametric_edit(): + """Objects with no active parametric edit must not trigger any + regenerator — the helper is called from undo_post which fires on + every undo, including undos that touch zero parametric drafts.""" + captured = [] + + def fake_dispatch(obj): + captured.append(obj) + + with patch.dict(parametric_lifecycle.UNDO_REGENERATORS, {"wall": fake_dispatch}, clear=False), patch.object( + tool.Parametric, "is_object_editing", return_value=None + ): + parametric_lifecycle.resync_parametric_drafts_after_undo() + + assert captured == [] + + +def test_resync_dispatches_to_registered_regenerator_for_editing_object(): + """When an object is in parametric edit and its type has a registered + regenerator, the regenerator must run with that object as the sole + arg. This is the load-bearing branch: preview mesh re-renders from + current props, so the gizmo and preview re-sync.""" + captured = [] + + def fake_wall_regenerator(obj): + captured.append(obj) + + fake_feature = MagicMock(spec=tool.parametric.ParametricObject) + fake_feature.name = "wall" + + obj = bpy.data.objects.new("test_wall_obj", bpy.data.meshes.new("test_wall_mesh")) + try: + with patch.dict( + parametric_lifecycle.UNDO_REGENERATORS, {"wall": fake_wall_regenerator}, clear=False + ), patch.object(tool.Parametric, "is_object_editing", side_effect=lambda o: fake_feature if o is obj else None): + parametric_lifecycle.resync_parametric_drafts_after_undo() + finally: + bpy.data.objects.remove(obj, do_unlink=True) + + assert captured == [obj] + + +def test_resync_skips_editing_object_whose_type_has_no_regenerator(): + """A parametric type without an ``UNDO_REGENERATORS`` entry (door / + window / array — IFC-derived preview, no desync) must not raise; the + helper silently skips it.""" + fake_feature = MagicMock(spec=tool.parametric.ParametricObject) + fake_feature.name = "door" # door has no entry in UNDO_REGENERATORS + + obj = bpy.data.objects.new("test_door_obj", bpy.data.meshes.new("test_door_mesh")) + try: + with patch.object( + tool.Parametric, "is_object_editing", side_effect=lambda o: fake_feature if o is obj else None + ): + parametric_lifecycle.resync_parametric_drafts_after_undo() + finally: + bpy.data.objects.remove(obj, do_unlink=True) diff --git a/src/bonsai/test/bim/module/model/test_wall_array_child_filter_forward_compat.py b/src/bonsai/test/bim/module/model/test_wall_array_child_filter_forward_compat.py new file mode 100644 index 0000000000..6c1b367b7c --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_wall_array_child_filter_forward_compat.py @@ -0,0 +1,133 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Forward-compat AST guard: every multi-object wall topology GizmoGroup +filters Bonsai array children via ``_wall_topology_gizmo_poll_gate`` or +the central ``any_selected_is_array_child`` predicate. + +Allow-list (gizmos intentionally outside the rule): + +- ``GizmoWallEdition`` — single-object parametric edit gizmo. Its base + parametric poll already filters array children. +- ``GizmoWallFilletPreview`` — the preview-owner whose poll must fire + WHILE its own preview is active; routing it through the topology gate + would self-block it. + +Host-opening gizmos live in a sibling module and intentionally use the +loose base ``_wall_gizmo_poll_gate``: openings track with the child +through ``regenerate_array`` and stay authorable on children. + +A new wall ``GizmoGroup`` added without the filter (and not added to the +allow-list with an explanation) fails this test.""" + +import ast +import inspect + +import bpy +import pytest + +pytestmark = pytest.mark.model + +# Wall gizmo groups intentionally outside the rule. Add a new entry only +# with the in-code reasoning above. +_ALLOWLIST = frozenset({"GizmoWallEdition", "GizmoWallFilletPreview"}) + +_REQUIRED_CALLEES = frozenset({"_wall_topology_gizmo_poll_gate", "any_selected_is_array_child"}) + + +def _wall_module_source(): + from bonsai.bim.module.model import wall as wall_mod + + return inspect.getsource(wall_mod), wall_mod.__name__ + + +def _wall_gizmo_group_classes(): + """All ``bpy.types.GizmoGroup`` subclasses defined locally in wall.py.""" + from bonsai.bim.module.model import wall as wall_mod + + out = [] + for name in dir(wall_mod): + obj = getattr(wall_mod, name) + if not isinstance(obj, type): + continue + if not issubclass(obj, bpy.types.GizmoGroup) or obj is bpy.types.GizmoGroup: + continue + if obj.__module__ != wall_mod.__name__: + continue + out.append((name, obj)) + return out + + +def _poll_function_calls(class_node): + """Names of every function called inside ``class_node``'s ``poll`` body. + + ``ast.Call.func`` may be an ``ast.Name`` (bare call) or an ``ast.Attribute`` + (dotted call). For the dotted case the leaf attribute is returned so + ``tool.Blender.Modifier.any_selected_is_array_child(...)`` registers as + ``any_selected_is_array_child``.""" + poll_node = next( + (node for node in class_node.body if isinstance(node, ast.FunctionDef) and node.name == "poll"), + None, + ) + if poll_node is None: + return None + names = set() + for sub in ast.walk(poll_node): + if not isinstance(sub, ast.Call): + continue + func = sub.func + if isinstance(func, ast.Name): + names.add(func.id) + elif isinstance(func, ast.Attribute): + names.add(func.attr) + return names + + +def test_every_wall_gizmo_group_filters_array_children_or_is_allowlisted(): + """For every locally-defined wall ``GizmoGroup`` not in the allow-list, + its ``poll`` must call ``_wall_gizmo_poll_gate`` or the central + ``any_selected_is_array_child`` predicate. A failure surfaces the list + of offending classes — the fix is a single early-return through the + central helper, mirroring the existing peers.""" + source, _module_name = _wall_module_source() + tree = ast.parse(source) + class_nodes = {node.name: node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)} + offenders = [] + for class_name, _cls in _wall_gizmo_group_classes(): + if class_name in _ALLOWLIST: + continue + node = class_nodes.get(class_name) + if node is None: + offenders.append((class_name, "AST parse did not find the class")) + continue + calls = _poll_function_calls(node) + if calls is None: + offenders.append((class_name, "no poll() defined; expected the array-child filter call")) + continue + if not (calls & _REQUIRED_CALLEES): + offenders.append((class_name, f"poll() does not call any of {sorted(_REQUIRED_CALLEES)}")) + + assert not offenders, ( + "Wall GizmoGroup classes missing the array-child filter: " + + ", ".join(f"{n} — {why}" for n, why in offenders) + + ". Route the poll through `_wall_topology_gizmo_poll_gate(context)` " + "so the central `any_selected_is_array_child` filter applies, or add " + "the class to the file's allow-list with a documented reason." + ) diff --git a/src/bonsai/test/bim/module/model/test_wall_gizmo_poll_gate.py b/src/bonsai/test/bim/module/model/test_wall_gizmo_poll_gate.py new file mode 100644 index 0000000000..048b04c54b --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_wall_gizmo_poll_gate.py @@ -0,0 +1,147 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Behaviour contract: every wall gizmo group hides while a parametric-edit +preview is active. + +Enumerates wall gizmo groups by walking the wall module for ``bpy.types.GizmoGroup`` +subclasses rather than naming them — adding a new wall gizmo group automatically +joins the test. The test then asserts the BEHAVIOUR (poll returns False when +``preview_base.any_preview_active`` is True) without pinning the name of the +helper function the gizmo uses internally to enforce it.""" + +import inspect +from unittest.mock import patch + +import bpy +import pytest + +pytestmark = pytest.mark.model + + +def _wall_gizmo_groups(): + """Walk the wall module for ``bpy.types.GizmoGroup`` subclasses defined + locally (skip imported references). Returns a list of (name, cls) tuples. + + A gizmo group whose ``poll`` legitimately needs to fire WHILE a preview + is active — i.e. it IS the preview's own gizmo group — is excluded by + convention: classes whose bl_idname references the preview surface + (``preview`` in the idname) are the preview-owner exception.""" + from bonsai.bim.module.model import wall as wall_mod + + out = [] + for name in dir(wall_mod): + obj = getattr(wall_mod, name) + if not isinstance(obj, type): + continue + if not issubclass(obj, bpy.types.GizmoGroup) or obj is bpy.types.GizmoGroup: + continue + # Local definitions only — skip re-exports / aliases. + if obj.__module__ != wall_mod.__name__: + continue + # Preview-owner exception: the gizmo group that drives a preview + # itself must remain visible while its preview is active, so a + # "no preview active" gate would self-block it. The bl_idname + # contains the substring 'preview' for these groups by Bonsai + # convention (e.g. OBJECT_GGT_bim_wall_fillet_preview). + bl_idname = getattr(obj, "bl_idname", "") or "" + if "preview" in bl_idname.lower(): + continue + out.append((name, obj)) + return out + + +class TestWallGizmoGroupsHideDuringPreview: + """Behaviour contract: a parametric-edit preview is the only interactive + surface in the viewport, so every sister wall gizmo must self-hide via + its poll. The test exercises this BEHAVIOUR — when ``any_preview_active`` + reports True, every wall gizmo's poll returns False — without pinning + the helper function name each poll uses internally.""" + + def test_discovery_finds_wall_gizmo_groups(self): + """Sanity check: at least one wall gizmo group is found. If this fails, + the discovery walk drifted out of sync with the module structure (e.g. + wall gizmo groups got moved to a separate file).""" + groups = _wall_gizmo_groups() + assert groups, "Expected at least one wall GizmoGroup subclass in wall.py — discovery walk broke?" + + def test_every_wall_gizmo_hides_when_a_preview_is_active(self): + """For each discovered wall gizmo group, mock ``any_preview_active`` to + True and call ``poll(bpy.context)``. Every poll must return False — + any True is a poll that wouldn't hide during a fillet/bend preview, + leaving the user with two competing icon stacks on the same selection.""" + groups = _wall_gizmo_groups() + offenders = [] + with patch("bonsai.bim.module.model.preview_base.any_preview_active", return_value=True): + for name, cls in groups: + poll = getattr(cls, "poll", None) + if poll is None: + # Inherits poll from a mixin / base — the base poll's gating + # is covered separately. Skip rather than crash. + continue + try: + result = poll(bpy.context) + except Exception as exc: # noqa: BLE001 + offenders.append((name, f"poll raised: {type(exc).__name__}: {exc}")) + continue + if result: + offenders.append((name, "poll returned True with preview active")) + + assert not offenders, ( + "Wall gizmo polls that don't gate on any_preview_active " + "(or raise instead of returning False): " + + ", ".join(f"{n} — {why}" for n, why in offenders) + + ". Hide sister gizmos during previews so the preview is the only " + "interactive surface in the viewport. The conventional path is to " + "early-return from poll when preview_base.any_preview_active(context) " + "is True." + ) + + +class TestBaseParametricGizmoPollHidesDuringPreview: + """Mirror of the wall-specific test for the cross-feature parametric + framework: door / window / stair / roof / railing / array all inherit + ``BaseParametricGizmoGroup``. Its poll must also short-circuit on + ``any_preview_active`` so sister features behave consistently with walls.""" + + def test_base_parametric_poll_returns_false_when_a_preview_is_active(self): + from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup + + # The base poll requires an active selected object before checking the + # preview gate. Mock both the selected-object check (return a sentinel) + # AND the gate so the test exercises ONLY the preview short-circuit. + with patch("bonsai.tool.Blender.get_active_object", return_value=object()): + with patch("bonsai.tool.Blender.are_viewport_gizmos_enabled", return_value=True): + with patch( + "bonsai.bim.module.model.preview_base.any_preview_active", + return_value=True, + ): + assert BaseParametricGizmoGroup.poll(bpy.context) is False + + +class TestModulePathIsFindable: + """If wall.py is split across multiple modules (e.g. wall_gizmos.py), + update ``_wall_gizmo_groups`` to walk each. This sanity check fails first + so the diagnostic message is obvious.""" + + def test_wall_module_resolves(self): + from bonsai.bim.module.model import wall as wall_mod + + assert inspect.ismodule(wall_mod) diff --git a/src/bonsai/test/bim/module/model/test_wall_gizmos.py b/src/bonsai/test/bim/module/model/test_wall_gizmos.py new file mode 100644 index 0000000000..c3b97e9466 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_wall_gizmos.py @@ -0,0 +1,401 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Unit tests for the poll() preconditions of wall billboarding gizmo groups. + +These tests patch ``tool.Blender`` / ``tool.Ifc`` / ``tool.Model`` so the poll +logic can be exercised without a real IFC fixture. Each test pins one of the +gates ``poll()`` walks, so any silent regression in the gate order or in the +LAYER3-active / LAYER2-other contract is caught by a dedicated assertion.""" + +from types import SimpleNamespace +from unittest.mock import patch + +import bpy +import pytest + +pytestmark = pytest.mark.wall + + +class _Obj: + """Hashable, name-bearing stand-in for a ``bpy.types.Object`` selection + slot. ``SimpleNamespace`` defines ``__eq__`` (and so ``__hash__ = None``) + which makes it unusable inside the ``set()`` that + ``get_selected_objects()`` returns; a plain class falls back to + identity-based hashing and works inside both ``set`` and ``list``.""" + + def __init__(self, name: str) -> None: + self.name = name + + +def _make_context(active, selected): + """Build a minimal ``context`` stub with the two attributes ``poll()`` reads.""" + return SimpleNamespace(active_object=active, selected_objects=list(selected)) + + +def _patch_tools(prefs_on, selected, active_element, other_element, active_usage, other_usage): + """Return a stack of patches that simulate one selection / IFC state for poll(). + + ``prefs.gizmos.draw_gizmos_in_3d_viewport`` is the top-level toggle. The + selection set, the IFC entity lookup, and the usage-type lookup are stubbed + so the test only depends on the predicate ordering in poll().""" + prefs = SimpleNamespace(gizmos=SimpleNamespace(draw_gizmos_in_3d_viewport=prefs_on)) + + entity_map = {} + usage_map = {} + # active_element/other_element are matched by object identity from the selected set + if len(selected) == 2: + entity_map[id(selected[0])] = active_element + entity_map[id(selected[1])] = other_element + usage_map[id(active_element)] = active_usage + usage_map[id(other_element)] = other_usage + + def get_entity(obj): + return entity_map.get(id(obj)) + + def get_usage_type(element): + return usage_map.get(id(element)) + + from bonsai import tool + + return [ + patch.object(tool.Blender, "get_addon_preferences", return_value=prefs), + patch.object(tool.Blender, "get_selected_objects", return_value=set(selected)), + patch.object(tool.Ifc, "get_entity", side_effect=get_entity), + patch.object(tool.Model, "get_usage_type", side_effect=get_usage_type), + # The array-child filter is pinned by its own test file; stub it here + # so these poll tests stay focused on the count / layer-usage gates + # and don't have to scaffold the memoization cache key. + patch.object(tool.Blender.Modifier, "any_selected_is_array_child", return_value=False), + ] + + +def _run_poll(prefs_on, active_is_in_selected, len_override, active_usage, other_usage, active_has_entity=True): + from bonsai.bim.module.model.wall import GizmoWallExtendVertically + + slab_obj = _Obj("slab") + wall_obj = _Obj("wall") + active = slab_obj if active_is_in_selected else _Obj("active_extra") + if len_override is None: + selected = [slab_obj, wall_obj] + else: + selected = [_Obj(f"obj_{i}") for i in range(len_override)] + if active_is_in_selected and selected: + active = selected[0] + + slab_element = object() if active_has_entity else None + wall_element = object() + + patches = _patch_tools(prefs_on, selected, slab_element, wall_element, active_usage, other_usage) + for p in patches: + p.start() + try: + return GizmoWallExtendVertically.poll(_make_context(active, selected)) + finally: + for p in patches: + p.stop() + + +def test_poll_accepts_layer3_active_with_layer2_other(): + assert ( + _run_poll( + prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage="LAYER2" + ) + is True + ) + + +def test_poll_rejects_when_gizmo_toggle_off(): + assert ( + _run_poll( + prefs_on=False, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage="LAYER2" + ) + is False + ) + + +def test_poll_rejects_when_selection_count_is_not_two(): + assert ( + _run_poll( + prefs_on=True, active_is_in_selected=True, len_override=3, active_usage="LAYER3", other_usage="LAYER2" + ) + is False + ) + assert ( + _run_poll( + prefs_on=True, active_is_in_selected=True, len_override=1, active_usage="LAYER3", other_usage="LAYER2" + ) + is False + ) + + +def test_poll_rejects_when_active_has_no_ifc_entity(): + assert ( + _run_poll( + prefs_on=True, + active_is_in_selected=True, + len_override=None, + active_usage="LAYER3", + other_usage="LAYER2", + active_has_entity=False, + ) + is False + ) + + +def test_poll_rejects_when_active_is_not_layer3(): + # A LAYER2 active (wall) must NOT trigger this gizmo — the wall-join gizmo + # owns that case, and extend_walls_to_underside expects the slab to be active. + assert ( + _run_poll( + prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER2", other_usage="LAYER2" + ) + is False + ) + # Active with no usage at all (generic mesh, e.g. an opening blocker) is also rejected. + assert ( + _run_poll(prefs_on=True, active_is_in_selected=True, len_override=None, active_usage=None, other_usage="LAYER2") + is False + ) + + +def test_poll_rejects_when_other_is_not_layer2_wall(): + assert ( + _run_poll( + prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage="LAYER3" + ) + is False + ) + assert ( + _run_poll(prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage=None) + is False + ) + + +# ---------------------------------------------------------------------------- +# _iter_path_connections — IfcRelConnectsPathElements inverse-graph walk +# ---------------------------------------------------------------------------- +# +# Normalises both ConnectedTo and ConnectedFrom orientations to (other, self_ct, +# other_ct) so callers always read "self first" regardless of which side of the +# rel this wall was authored on. Non-wall partners and malformed (None) refs are +# filtered out so per-frame gizmo positioning survives partial IFC state. + + +def _make_path_rel(relating, related, relating_ct, related_ct, kind="IfcRelConnectsPathElements"): + """Build a stub IfcRelConnectsPathElements for inverse-walk tests.""" + return SimpleNamespace( + is_a=lambda name, _k=kind: name == _k, + RelatingElement=relating, + RelatedElement=related, + RelatingConnectionType=relating_ct, + RelatedConnectionType=related_ct, + ) + + +def _run_iter_path_connections(elem, *, partner_predicate=lambda _e: True): + from bonsai import tool + from bonsai.bim.module.model.wall import _iter_path_connections + + with patch.object(tool.Parametric, "is_path_connectable_wall", side_effect=partner_predicate): + return _iter_path_connections(elem) + + +def test_iter_path_connections_empty_inverses_yields_nothing(): + elem = SimpleNamespace(ConnectedTo=[], ConnectedFrom=[]) + assert _run_iter_path_connections(elem) == [] + + +def test_iter_path_connections_connected_to_orientation_is_self_first(): + # Self is the rel's RelatingElement → its connection type is RelatingConnectionType. + self_elem = object() + other = object() + rel = _make_path_rel(relating=self_elem, related=other, relating_ct="ATEND", related_ct="ATSTART") + elem = SimpleNamespace(ConnectedTo=[rel], ConnectedFrom=[]) + assert _run_iter_path_connections(elem) == [(other, "ATEND", "ATSTART")] + + +def test_iter_path_connections_connected_from_orientation_is_self_first(): + # Self is the rel's RelatedElement → its connection type is RelatedConnectionType. + # The helper must FLIP the tuple so callers still see (other, self_ct, other_ct). + self_elem = object() + other = object() + rel = _make_path_rel(relating=other, related=self_elem, relating_ct="ATSTART", related_ct="ATEND") + elem = SimpleNamespace(ConnectedTo=[], ConnectedFrom=[rel]) + assert _run_iter_path_connections(elem) == [(other, "ATEND", "ATSTART")] + + +def test_iter_path_connections_skips_non_path_rels(): + # IfcRelAggregates, IfcRelContainedInSpatialStructure, etc. share the + # ConnectedTo/ConnectedFrom inverse arrays — only IfcRelConnectsPathElements + # carries the per-end connection-type semantics we care about. + self_elem = object() + other = object() + non_path = _make_path_rel( + relating=self_elem, related=other, relating_ct="ATSTART", related_ct="ATEND", kind="IfcRelAggregates" + ) + path = _make_path_rel(relating=self_elem, related=other, relating_ct="ATEND", related_ct="ATSTART") + elem = SimpleNamespace(ConnectedTo=[non_path, path], ConnectedFrom=[]) + assert _run_iter_path_connections(elem) == [(other, "ATEND", "ATSTART")] + + +def test_iter_path_connections_skips_non_wall_partners(): + # Walls may path-connect to non-wall elements (columns, beams). The single- + # wall unjoin gizmo only surfaces wall-to-wall joins to match the existing + # two-wall gizmo's scope. + self_elem = object() + wall_partner = object() + non_wall_partner = object() + rel_wall = _make_path_rel(relating=self_elem, related=wall_partner, relating_ct="ATEND", related_ct="ATSTART") + rel_non_wall = _make_path_rel( + relating=self_elem, related=non_wall_partner, relating_ct="ATEND", related_ct="ATSTART" + ) + elem = SimpleNamespace(ConnectedTo=[rel_wall, rel_non_wall], ConnectedFrom=[]) + result = _run_iter_path_connections(elem, partner_predicate=lambda e: e is wall_partner) + assert result == [(wall_partner, "ATEND", "ATSTART")] + + +def test_iter_path_connections_includes_fillet_corner_partner(): + # Fillet-corner walls carry no LAYER2 usage but are still valid path + # partners. The enumeration must use the same predicate the gizmo group's + # poll uses for the host wall — otherwise the corner is silently dropped + # from the neighbour's connection list and looks unconnected from the + # LAYER2 wall's perspective. + self_elem = object() + fillet_partner = object() + rel = _make_path_rel(relating=self_elem, related=fillet_partner, relating_ct="ATEND", related_ct="ATSTART") + elem = SimpleNamespace(ConnectedTo=[rel], ConnectedFrom=[]) + result = _run_iter_path_connections(elem, partner_predicate=lambda e: e is fillet_partner) + assert result == [(fillet_partner, "ATEND", "ATSTART")] + + +def test_iter_path_connections_tolerates_none_partner_refs(): + # Malformed / partial IFC files can leave a rel's element ref unset. + # Without a None guard, the partner predicate would receive None and + # raise on `.is_a(...)` mid-frame, silently breaking the gizmo group. + self_elem = object() + other = object() + rel_none = _make_path_rel(relating=self_elem, related=None, relating_ct="ATEND", related_ct="ATSTART") + rel_ok = _make_path_rel(relating=self_elem, related=other, relating_ct="ATSTART", related_ct="ATEND") + elem = SimpleNamespace(ConnectedTo=[rel_none, rel_ok], ConnectedFrom=[]) + assert _run_iter_path_connections(elem) == [(other, "ATSTART", "ATEND")] + + +def test_iter_path_connections_walks_both_inverses_in_order(): + # A wall can sit on both sides of different path rels (e.g. authored once + # as the RelatingElement, once as the RelatedElement). The helper walks + # ConnectedTo first, then ConnectedFrom — pinning the order so callers can + # depend on it for icon-slot allocation. + self_elem = object() + p1 = object() + p2 = object() + rel_to = _make_path_rel(relating=self_elem, related=p1, relating_ct="ATSTART", related_ct="ATSTART") + rel_from = _make_path_rel(relating=p2, related=self_elem, relating_ct="ATEND", related_ct="ATEND") + elem = SimpleNamespace(ConnectedTo=[rel_to], ConnectedFrom=[rel_from]) + assert _run_iter_path_connections(elem) == [(p1, "ATSTART", "ATSTART"), (p2, "ATEND", "ATEND")] + + +# ---------------------------------------------------------------------------- +# _perpendicular_wall_params — clamping + side detection for the +# "add perpendicular wall at cursor" gizmo and its operator. +# ---------------------------------------------------------------------------- +# +# Pure scalar math. The dead-zone is ``CURSOR_STACK_OFFSET`` — inside it the +# on-axis split / extend-X icons own the click and this helper returns None. + + +def _wall_consts(): + from bonsai.bim.module.model.wall import GizmoWallEdition + + return GizmoWallEdition.CURSOR_STACK_OFFSET + + +def _run_perp_params(cursor_x, cursor_y, anchor_x=0.0, length=5.0): + from bonsai.bim.module.model.wall import _perpendicular_wall_params + + return _perpendicular_wall_params(cursor_x, cursor_y, anchor_x, length) + + +def test_perpendicular_params_on_axis_returns_none(): + assert _run_perp_params(cursor_x=2.0, cursor_y=0.0) is None + + +def test_perpendicular_params_at_dead_zone_boundary_returns_none(): + # Inclusive boundary: at exactly the threshold the on-axis icons still own + # the click; the gizmo only takes over strictly past the dead zone. + threshold = _wall_consts() + assert _run_perp_params(cursor_x=2.0, cursor_y=threshold) is None + assert _run_perp_params(cursor_x=2.0, cursor_y=-threshold) is None + + +def test_perpendicular_params_just_past_dead_zone_returns_params(): + threshold = _wall_consts() + result = _run_perp_params(cursor_x=2.0, cursor_y=threshold + 0.01) + assert result is not None + clamped_x, length, side = result + assert clamped_x == pytest.approx(2.0) + assert length == pytest.approx(threshold + 0.01) + assert side == 1.0 + + +def test_perpendicular_params_negative_y_flips_side_sign(): + result = _run_perp_params(cursor_x=2.0, cursor_y=-1.5) + assert result is not None + _, length, side = result + # Length is always positive — the side sign carries the direction so the + # operator can pick the +90° vs -90° rotation without sign-flipping length. + assert length == pytest.approx(1.5) + assert side == -1.0 + + +def test_perpendicular_params_clamps_low_when_cursor_left_of_wall(): + result = _run_perp_params(cursor_x=-2.0, cursor_y=1.5, anchor_x=0.0, length=5.0) + assert result is not None + clamped_x, _length, _side = result + assert clamped_x == pytest.approx(0.0) + + +def test_perpendicular_params_clamps_high_when_cursor_right_of_wall(): + result = _run_perp_params(cursor_x=10.0, cursor_y=1.5, anchor_x=0.0, length=5.0) + assert result is not None + clamped_x, _length, _side = result + assert clamped_x == pytest.approx(5.0) + + +def test_perpendicular_params_respects_nonzero_anchor_x(): + # Non-zero anchor_x shifts the wall span; clamping must follow. + result = _run_perp_params(cursor_x=0.5, cursor_y=1.5, anchor_x=2.0, length=5.0) + assert result is not None + clamped_x, _length, _side = result + assert clamped_x == pytest.approx(2.0) + + result = _run_perp_params(cursor_x=10.0, cursor_y=1.5, anchor_x=2.0, length=5.0) + assert result is not None + clamped_x, _length, _side = result + assert clamped_x == pytest.approx(7.0) + + +def test_perpendicular_params_in_range_passes_cursor_x_through(): + result = _run_perp_params(cursor_x=3.0, cursor_y=1.5, anchor_x=0.0, length=5.0) + assert result is not None + clamped_x, length, side = result + assert clamped_x == pytest.approx(3.0) + assert length == pytest.approx(1.5) + assert side == 1.0 diff --git a/src/bonsai/test/bim/module/model/test_wall_gizmos_array_children.py b/src/bonsai/test/bim/module/model/test_wall_gizmos_array_children.py new file mode 100644 index 0000000000..81f5ef5b15 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_wall_gizmos_array_children.py @@ -0,0 +1,237 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Behaviour contract: wall topology gizmos and operators reject any +selection that contains a Bonsai array child. Discovers gated gizmo +groups and guarded operators by source inspection so additions inherit +the rule automatically.""" + +from types import SimpleNamespace +from unittest.mock import patch + +import bpy +import pytest + +pytestmark = pytest.mark.model + + +def _wall_gizmo_groups_using_gate(): + """Wall-module ``bpy.types.GizmoGroup`` subclasses whose ``poll`` calls + ``_wall_topology_gizmo_poll_gate``. Discovered by source inspection so + the test tracks the gate's user set as the module grows.""" + import inspect + + from bonsai.bim.module.model import wall as wall_mod + + out = [] + for name in dir(wall_mod): + obj = getattr(wall_mod, name) + if not isinstance(obj, type): + continue + if not issubclass(obj, bpy.types.GizmoGroup) or obj is bpy.types.GizmoGroup: + continue + if obj.__module__ != wall_mod.__name__: + continue + poll = obj.__dict__.get("poll") + if poll is None: + continue + try: + src = inspect.getsource(poll) + except (OSError, TypeError): + continue + if "_wall_topology_gizmo_poll_gate" not in src: + continue + out.append((name, obj)) + return out + + +def _wall_operators_with_array_child_guard(): + """Wall-module ``bpy.types.Operator`` subclasses whose ``poll`` rejects + array-child selections, either by referencing the central predicate + directly or by routing through the shared ``_poll_reject_array_children`` + helper that wraps it. The operator-level guard is defence in depth + against keymap / F3 paths that bypass the gizmo entirely.""" + import inspect + + from bonsai.bim.module.model import wall as wall_mod + + out = [] + for name in dir(wall_mod): + obj = getattr(wall_mod, name) + if not isinstance(obj, type): + continue + if not issubclass(obj, bpy.types.Operator) or obj is bpy.types.Operator: + continue + if obj.__module__ != wall_mod.__name__: + continue + poll = obj.__dict__.get("poll") + if poll is None: + continue + try: + src = inspect.getsource(poll) + except (OSError, TypeError): + continue + if "any_selected_is_array_child" not in src and "_poll_reject_array_children" not in src: + continue + out.append((name, obj)) + return out + + +class TestWallGizmoGroupsHideOnArrayChildSelection: + def test_discovery_finds_wall_multi_object_gizmo_groups(self): + groups = _wall_gizmo_groups_using_gate() + assert groups, ( + "Expected at least one wall GizmoGroup whose poll calls " + "_wall_gizmo_poll_gate — discovery walk drifted out of sync?" + ) + + def test_every_gated_wall_gizmo_hides_when_any_selection_is_array_child(self): + """Mocks the central ``any_selected_is_array_child`` predicate to True + and asserts every gizmo whose poll routes through + ``_wall_gizmo_poll_gate`` returns False. The point is the BEHAVIOUR: + a child wall in the selection must never surface a topology gizmo, + regardless of which gate function the poll calls internally.""" + groups = _wall_gizmo_groups_using_gate() + offenders = [] + with patch("bonsai.tool.Blender.are_viewport_gizmos_enabled", return_value=True): + with patch("bonsai.bim.module.model.preview_base.any_preview_active", return_value=False): + with patch( + "bonsai.tool.Blender.Modifier.any_selected_is_array_child", + return_value=True, + ): + for name, cls in groups: + try: + result = cls.poll(bpy.context) + except Exception as exc: # noqa: BLE001 + offenders.append((name, f"poll raised: {type(exc).__name__}: {exc}")) + continue + if result: + offenders.append((name, "poll returned True with array child selected")) + + assert not offenders, ( + "Wall gizmo polls that surface on array-child selections: " + + ", ".join(f"{n} — {why}" for n, why in offenders) + + ". Route the poll through _wall_topology_gizmo_poll_gate so the " + "central any_selected_is_array_child filter applies." + ) + + +class TestWallOperatorsRejectArrayChildSelection: + def test_discovery_finds_wall_topology_operators(self): + ops = _wall_operators_with_array_child_guard() + assert ops, ( + "Expected at least one wall Operator whose poll rejects array-child " + "selections (via any_selected_is_array_child or _poll_reject_array_children) " + "— discovery walk drifted out of sync?" + ) + + def test_every_guarded_wall_operator_polls_false_on_array_child_selection(self): + """Operators reachable from keymaps / F3 must reject array-child + invocation independently of the gizmo gating, because not every + invocation path goes through a gizmo. The shared predicate makes + this a one-line guard per operator; this test pins it for every + operator that opted in.""" + ops = _wall_operators_with_array_child_guard() + offenders = [] + with patch( + "bonsai.tool.Blender.Modifier.any_selected_is_array_child", + return_value=True, + ): + with patch("bonsai.tool.Model.has_selected_ifc_objects", return_value=True): + with patch("bonsai.tool.Model.get_selected_ifc_objects", return_value=[]): + for name, cls in ops: + try: + result = cls.poll(bpy.context) + except Exception as exc: # noqa: BLE001 + offenders.append((name, f"poll raised: {type(exc).__name__}: {exc}")) + continue + if result: + offenders.append((name, "poll returned True with array child selected")) + + assert not offenders, ( + "Wall topology operators that accept array-child selections: " + + ", ".join(f"{n} — {why}" for n, why in offenders) + + ". Route the poll through `_poll_reject_array_children(cls)` (the " + "shared helper that sets the standard poll message and reuses the " + "central `any_selected_is_array_child` predicate)." + ) + + +class TestAnySelectedIsArrayChildHelper: + """Smoke checks on the central predicate. Returns ``False`` when nothing + is selected; returns ``True`` when at least one selected element passes + ``is_array_child``.""" + + def test_returns_false_with_empty_selection(self): + from bonsai import tool + + with patch.object(tool.Blender, "get_selected_objects", return_value=[]): + assert tool.Blender.Modifier.any_selected_is_array_child() is False + + def test_returns_true_when_any_selected_passes_predicate(self): + from bonsai import tool + + child_obj, child_element = SimpleNamespace(name="child"), object() + parent_obj, parent_element = SimpleNamespace(name="parent"), object() + + def get_entity(obj): + return {id(child_obj): child_element, id(parent_obj): parent_element}.get(id(obj)) + + def is_array_child(element): + return element is child_element + + with patch.object(tool.Blender, "get_selected_objects", return_value=[parent_obj, child_obj]): + with patch.object(tool.Ifc, "get_entity", side_effect=get_entity): + with patch.object(tool.Blender.Modifier, "is_array_child", side_effect=is_array_child): + assert tool.Blender.Modifier.any_selected_is_array_child() is True + + def test_returns_false_when_no_selected_passes_predicate(self): + from bonsai import tool + + parent_obj, parent_element = SimpleNamespace(name="parent"), object() + with patch.object(tool.Blender, "get_selected_objects", return_value=[parent_obj]): + with patch.object(tool.Ifc, "get_entity", return_value=parent_element): + with patch.object(tool.Blender.Modifier, "is_array_child", return_value=False): + assert tool.Blender.Modifier.any_selected_is_array_child() is False + + +class TestHostOpeningGizmoStaysAvailableOnArrayChildren: + """Openings on array children are array-safe: ``regenerate_array`` + applies opening cuts after replicating child geometry, so an opening + authored on a child survives regen and tracks with the replicated + instance. The host-opening gizmos therefore route through the loose + base wall gate, not the tighter topology gate that excludes + children.""" + + def test_host_opening_module_does_not_apply_topology_gate(self): + import inspect + + from bonsai.bim.module.model import host_add_opening_gizmo + + src = inspect.getsource(host_add_opening_gizmo) + assert "_wall_topology_gizmo_poll_gate" not in src, ( + "host-opening gizmo module references the topology gate; that " + "would suppress add-opening on array-child hosts. Openings " + "track with the regenerated child via the array regen pipeline." + ) + assert "any_selected_is_array_child" not in src, ( + "host-opening gizmo module references any_selected_is_array_child; " + "openings are array-safe, drop the filter." + ) diff --git a/src/bonsai/test/bim/module/model/test_wall_gizmos_forward_compat.py b/src/bonsai/test/bim/module/model/test_wall_gizmos_forward_compat.py new file mode 100644 index 0000000000..9efff09a52 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_wall_gizmos_forward_compat.py @@ -0,0 +1,386 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Forward-compat AST contracts for wall gizmo internals. + +Pins structural invariants that no per-call-site behavioural test can catch +on its own: the kind of "someone tidied the imports" regression that leaves +tests green but silently changes runtime semantics. Each contract names the +invariant it pins so a future revert tells the contributor exactly what the +rule is.""" + +import ast +import inspect +import textwrap + +import pytest + +pytestmark = pytest.mark.wall + + +def test_iter_path_connections_uses_path_connectable_predicate(): + """The partner filter must consult the looser ``is_path_connectable_wall`` + predicate, matching the host-side predicate used by the gizmo group's + poll. Strict ``is_wall`` rejects fillet-corner walls (which have no + LAYER2 usage by IFC spec), so a regression to ``is_wall`` would silently + drop fillet partners from the connection list — visible to the user as + "the corner looks unconnected from the adjacent wall's selection.\" """ + from bonsai.bim.module.model.wall import _iter_path_connections + + source = inspect.getsource(_iter_path_connections) + tree = ast.parse(source) + attr_names = {node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute)} + + assert "is_path_connectable_wall" in attr_names, ( + "_iter_path_connections must filter partners with is_path_connectable_wall — " + "the same predicate the gizmo group's poll uses on the host wall. " + "Symmetry between host and partner predicates is required for fillet " + "corners (no LAYER2 usage) to surface as connected from their LAYER2 " + "neighbours' perspective." + ) + assert "is_wall" not in attr_names, ( + "_iter_path_connections must NOT call .is_wall on partner elements — " + "that strict predicate drops fillet-corner walls. Use " + "is_path_connectable_wall instead." + ) + + +def test_gizmo_wall_link_toggle_invokes_partner_bbox_helper(): + """The wall subclass must call draw_wall_partner_bbox when its hover + state is active. Without this contract the partner-wall highlight + silently regresses if someone "tidies" the draw() override away.""" + from bonsai.bim.module.model import wall as wall_module + + source = textwrap.dedent(inspect.getsource(wall_module.GizmoWallLinkToggle.draw)) + tree = ast.parse(source) + attr_names = {node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute)} + call_names: set[str] = set() + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if isinstance(node.func, ast.Attribute): + call_names.add(node.func.attr) + elif isinstance(node.func, ast.Name): + call_names.add(node.func.id) + + assert "is_highlight" in attr_names, ( + "GizmoWallLinkToggle.draw must gate its highlight call on self.is_highlight — " + "without it the partner outline would draw every frame, not just on hover." + ) + assert "draw_wall_partner_bbox" in call_names, ( + "GizmoWallLinkToggle.draw must call draw_wall_partner_bbox to render the " + "partner outline. The shared composite in decorator.py is the canonical " + "trigger for this feature; replacing it with an ad-hoc draw call would " + "drift from the array-children bbox styling." + ) + + +def test_every_wall_gizmo_group_resolves_get_decoration_colors(): + """Any wall ``GizmoGroup`` whose ``setup()`` reads decoration colours via + ``self.get_decoration_colors()`` must inherit from a mixin that supplies + it (``gizmo.BaseParametricGizmoGroup`` or ``gizmo.BillboardingGizmoGroupMixin``). + Without the mixin the call AttributeErrors inside ``setup()``, Blender + logs the failure and skips the rest of ``setup()``, and every later + ``draw_prepare()`` blows up on whichever attribute the truncated setup + failed to assign — a silent, runtime-only regression that no other test + catches.""" + import bpy + + from bonsai.bim.module.model import wall as wall_module + + offenders: list[str] = [] + for name in dir(wall_module): + cls = getattr(wall_module, name) + if not inspect.isclass(cls): + continue + if inspect.getmodule(cls) is not wall_module: + continue + if not issubclass(cls, bpy.types.GizmoGroup): + continue + setup = cls.__dict__.get("setup") + if setup is None: + continue + try: + src = inspect.getsource(setup) + except (OSError, TypeError): + continue + if "self.get_decoration_colors()" not in src: + continue + if not hasattr(cls, "get_decoration_colors"): + offenders.append(cls.__name__) + + assert not offenders, ( + f"GizmoGroup subclasses {offenders} call self.get_decoration_colors() in " + "setup() but inherit from no class that provides it. Add " + "gizmo.BillboardingGizmoGroupMixin (or gizmo.BaseParametricGizmoGroup) to " + "the class bases — both define get_decoration_colors and are the canonical " + "wall-gizmo mixins." + ) + + +def test_host_add_opening_accepts_fillet_corner_active(): + """``is_supported_host`` (the gate ``GizmoHostAddOpening.poll`` dispatches + through) must classify walls via ``is_path_connectable_wall``, not the + strict ``is_wall`` predicate. Fillet-corner walls carry no LAYER2 usage + by IFC spec, so the strict predicate rejects them and the add-opening + icon never surfaces over a curved corner — symmetry with the join / + unjoin / extend wall gizmos (all of which already poll on the looser + predicate) is required for the user to drop openings into fillet + corners at all.""" + from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host + + source = textwrap.dedent(inspect.getsource(is_supported_host)) + tree = ast.parse(source) + attr_names = {node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute)} + + assert "is_path_connectable_wall" in attr_names, ( + "is_supported_host must gate walls on tool.Parametric.is_path_connectable_wall. " + "The strict is_wall predicate hides the add-opening gizmo over every " + "fillet-corner wall." + ) + assert "is_wall" not in attr_names, ( + "is_supported_host must NOT call .is_wall — that strict predicate drops " + "fillet-corner walls. Use is_path_connectable_wall instead, matching the " + "host gate every other wall-state gizmo group uses." + ) + + +def test_join_intersection_uses_l_and_t_glyphs(): + """``GizmoWallJoinIntersection.setup`` must bind the join icon to the L + glyph (``VIEW3D_GT_wall_corner``) and the extend-to icon to the T glyph + (``VIEW3D_GT_wall_tee``). The L / T pair makes the corner-join vs + extend-into-side distinction read at a glance — a regression to the + arrow-merge glyph for both icons makes them visually indistinguishable + once they're stacked at the same XY.""" + from bonsai.bim.module.model.wall import GizmoWallJoinIntersection + + source = textwrap.dedent(inspect.getsource(GizmoWallJoinIntersection.setup)) + assert '"VIEW3D_GT_wall_corner"' in source, ( + "GizmoWallJoinIntersection.setup must bind join_icon to VIEW3D_GT_wall_corner " + "(the L glyph). The arrow-merge glyph (VIEW3D_GT_merge) is the collinear-merge " + "case and was visually ambiguous with the extend-to icon when both were stacked." + ) + assert '"VIEW3D_GT_wall_tee"' in source, ( + "GizmoWallJoinIntersection.setup must bind extend_to_wall_icon to " + "VIEW3D_GT_wall_tee (the T glyph). The arrow-extend glyph was visually " + "ambiguous with the join icon when both were stacked." + ) + + +def test_join_intersection_stacks_along_screen_up_in_both_states(): + """``GizmoWallJoinIntersection.position_gizmos`` must route both the + joined (unjoin + fillet) and the intersecting (join + extend + fillet) + states through ``_stack_at`` so the icons stay individually clickable + in any view, including top / plan view where world-Z separation + collapses to zero on screen. A regression that re-introduces a + per-state ``billboarded_at(corner, ...)`` write outside ``_stack_at`` + silently flattens the stack back onto one screen pixel.""" + from bonsai.bim.module.model.wall import GizmoWallJoinIntersection + + source = textwrap.dedent(inspect.getsource(GizmoWallJoinIntersection.position_gizmos)) + tree = ast.parse(source) + call_names: set[str] = set() + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if isinstance(node.func, ast.Attribute): + call_names.add(node.func.attr) + elif isinstance(node.func, ast.Name): + call_names.add(node.func.id) + + assert "_stack_at" in call_names, ( + "GizmoWallJoinIntersection.position_gizmos must call self._stack_at to " + "lay icons along screen-up at the wall-top anchor. Direct " + "billboarded_at writes for the join/unjoin/extend/fillet icons bypass " + "the stacking contract and re-introduce the top-view collapse bug." + ) + + +def _get_wall_axis_callers_in(method) -> set[str]: + """Return the set of attribute chains in ``method``'s source that resolve + to ``tool.Model.get_wall_axis``. Empty set means the method does not read + from the mesh-bound-box axis source.""" + source = textwrap.dedent(inspect.getsource(method)) + tree = ast.parse(source) + offenders: set[str] = set() + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if not isinstance(func, ast.Attribute) or func.attr != "get_wall_axis": + continue + # Reconstruct the receiver chain to surface it in the assertion message. + chain: list[str] = [func.attr] + receiver = func.value + while isinstance(receiver, ast.Attribute): + chain.append(receiver.attr) + receiver = receiver.value + if isinstance(receiver, ast.Name): + chain.append(receiver.id) + offenders.add(".".join(reversed(chain))) + return offenders + + +def _method_writes_ifc_axis(method) -> bool: + """True iff ``method``'s body calls ``self.set_axis(...)`` — the only + path that writes a wall's IFC reference line via + ``ifcopenshell.api.geometry.assign_representation``. Methods that only + read ``axis["base"]`` / ``axis["side"]`` for layer-polygon work (slab + clipping, opening snap) never call ``set_axis`` and are not under this + rule.""" + source = textwrap.dedent(inspect.getsource(method)) + tree = ast.parse(source) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if isinstance(func, ast.Attribute) and func.attr == "set_axis": + return True + return False + + +def test_dumb_wall_joiner_axis_writers_read_ifc_reference_line(): + """Any ``DumbWallJoiner`` method that writes the IFC reference line + (via ``self.set_axis`` → ``ifcopenshell.api.geometry.assign_representation``) + must read its input axis from the IFC reference line too — not from + ``tool.Model.get_wall_axis``, whose X-extent comes from ``obj.bound_box`` + (the Body mesh AABB). The bound-box axis drifts past or short of the + IFC reference line at mitred / butt-jointed walls and at walls with end + openings; mixing it on input with the IFC axis on output produces + non-colinear sub-axes that compound through chained extend/split/join + edits. + + The IFC-anchored helper is ``tool.Wall.get_world_reference_line`` for + world-space endpoints, or ``ifcopenshell.util.representation.get_reference_line`` + for local-SI endpoints. + + Joiner methods that only read layer-polygon base/side (e.g. ``clip`` + for slab intersection) are exempt — they need the body footprint, not + the axis, and never call ``set_axis``.""" + from bonsai.bim.module.model.wall import DumbWallJoiner + + offenders: dict[str, set[str]] = {} + for name, method in inspect.getmembers(DumbWallJoiner, predicate=inspect.isfunction): + if not _method_writes_ifc_axis(method): + continue + bad_calls = _get_wall_axis_callers_in(method) + if bad_calls: + offenders[name] = bad_calls + + assert not offenders, ( + f"DumbWallJoiner methods that call self.set_axis must not read the " + f"bound-box-derived axis: {offenders}. Use " + "tool.Wall.get_world_reference_line for world-space endpoints, or " + "ifcopenshell.util.representation.get_reference_line for local-SI " + "endpoints. Mixing bound_box on input with IFC axis on output " + "produces non-colinear sub-axes that compound through chained " + "extend/split/join edits." + ) + + +def test_extend_walls_to_polyline_set_origin_uses_ifc_reference_line(): + """``ExtendWallsToPolylinePoint.set_origin`` seeds the polyline preview + anchor at one of the wall's axis endpoints. The downstream operator + (``DumbWallJoiner.extend``) projects the user's chosen target onto the + IFC reference line; if the preview anchor comes from + ``tool.Model.get_wall_axis`` (bound_box) the user sees the preview at + one endpoint and the wall lands at a different one — the visible + "extend falls short by a few cm/m" symptom.""" + from bonsai.bim.module.model.wall import ExtendWallsToPolylinePoint + + offenders = _get_wall_axis_callers_in(ExtendWallsToPolylinePoint.set_origin) + + assert not offenders, ( + f"ExtendWallsToPolylinePoint.set_origin must not read the bound-box-derived " + f"axis: {offenders}. Use tool.Wall.get_world_reference_line so the preview " + "anchor lands on the same IFC reference line the downstream extend operator " + "projects onto." + ) + + +def test_wall_toggle_openings_uses_idle_slots(): + """The wall's toggle_openings icon must be declared in + ``GizmoWallEdition.idle_slots`` so the base class lays it out at the + standard pen-row position. Routing it through ad-hoc setup helpers + instead would re-introduce the X-collision with the array's first + per-layer icon — the bug this contract was added to prevent.""" + from bonsai.bim.module.model.wall import GizmoWallEdition + + slot_names = {s.name for s in GizmoWallEdition.idle_slots} + assert "toggle_openings" in slot_names, ( + "GizmoWallEdition.idle_slots must contain a slot named 'toggle_openings'. " + "The base class derives its X position from the slot's tuple index so peer " + "groups (GizmoArrayEdition's per-layer icons) can query a real layout edge " + "via _idle_row_right_edge() instead of a hardcoded per-feature table." + ) + + +def test_no_pen_row_toggle_openings_helpers_remain(): + """The legacy ``setup_pen_row_toggle_openings_icon`` and + ``update_pen_row_toggle_openings_icon`` helpers were removed once + toggle_openings migrated into the ``idle_slots`` system. A re-introduced + helper would shadow the slot-driven layout — features calling it would + set up a second gizmo at a different X and the collision-prevention + contract would silently regress. + + Walks the wall and roof modules (the historical callers) plus + drawing/gizmos.py (the historical home) for any reference to either + name.""" + import bonsai.bim.module.drawing.gizmos as gizmos_mod + import bonsai.bim.module.model.roof as roof_mod + import bonsai.bim.module.model.wall as wall_mod + + forbidden = ("setup_pen_row_toggle_openings_icon", "update_pen_row_toggle_openings_icon") + for mod in (gizmos_mod, roof_mod, wall_mod): + source = inspect.getsource(mod) + for name in forbidden: + assert name not in source, ( + f"{mod.__name__} still references {name!r}. The toggle_openings icon " + f"is now declared via idle_slots; the ad-hoc helpers were removed to " + f"prevent layout drift between feature groups." + ) + + +def test_array_idle_max_x_walks_registry_not_hardcoded_dict(): + """``GizmoArrayEdition._resolve_feature_idle_max_x`` must query peer + parametric gizmo groups' ``_idle_row_right_edge`` rather than indexing + a hardcoded per-feature ``_FEATURE_IDLE_MAX_X`` dict. The dict approach + was the source of the toggle_openings ↔ array-layer-icon collision bug + on arrayed walls (find_for_element returns 'array' first, shadowing the + wall reservation).""" + from bonsai.bim.module.model.array import GizmoArrayEdition + + assert not hasattr(GizmoArrayEdition, "_FEATURE_IDLE_MAX_X"), ( + "GizmoArrayEdition._FEATURE_IDLE_MAX_X was a hardcoded per-feature dict " + "that shadowed peer groups' real idle rows for compound elements (arrayed " + "walls). It was replaced by a registry walk via REGISTRY + " + "_idle_row_right_edge() — re-introducing the dict would re-create the bug." + ) + + source = inspect.getsource(GizmoArrayEdition._resolve_feature_idle_max_x) + assert "_idle_row_right_edge" in source, ( + "_resolve_feature_idle_max_x must call peer_cls._idle_row_right_edge() so " + "the X position derives from each peer's actual declared idle_slots." + ) + assert "REGISTRY" in source, ( + "_resolve_feature_idle_max_x must iterate BaseParametricGizmoGroup.REGISTRY " + "to discover peer groups; find_for_element returns ONE entry and shadows " + "compound-element memberships." + ) diff --git a/src/bonsai/test/bim/module/model/test_wall_header_refresh.py b/src/bonsai/test/bim/module/model/test_wall_header_refresh.py new file mode 100644 index 0000000000..5038c06636 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_wall_header_refresh.py @@ -0,0 +1,105 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Regression tests for the post-IFC-commit refresh path. + +Two invariants: + +* Every commit bumps ``_geom_generation`` so caches keyed off it drop + stale entries on the next read. +* The BIM Tool header float refresh (``refresh_bim_tool_headers``) fires + only for commits whose operator is a parametric ``finish_op`` from + ``tool.Parametric.EDIT_TYPES`` — the validate-gizmo path. Other + operators skip it; their commit context may lack the view-layer + attributes the refresh reads.""" + +import types +from unittest.mock import MagicMock, patch + +import bpy +import pytest + +pytestmark = pytest.mark.wall + + +def test_refresh_post_commit_bumps_generation_for_every_operator(): + """The generation counter advances on every commit, regardless of + operator class — it's the cache-invalidation signal for any code + keyed off ``tool.Parametric.get_geom_generation()``.""" + from bonsai import tool + + before = tool.Parametric.get_geom_generation() + tool.Parametric.refresh_post_commit(MagicMock(bl_idname="bim.append_library_element")) + assert tool.Parametric.get_geom_generation() == before + 1 + + +def test_refresh_post_commit_refreshes_headers_for_validate_gizmo_operators(): + """Operators whose ``bl_idname`` matches a ``ParametricObject.finish_op`` + in ``EDIT_TYPES`` are the validate-gizmo path: selection didn't + change, but the IFC values backing the BIM Tool header did. The + commit hook must push the new IFC state into the header floats.""" + import bonsai.bim.handler as handler + from bonsai import tool + + finish_op_idname = tool.Parametric.EDIT_TYPES[0].finish_op + with patch.object(handler, "refresh_bim_tool_headers") as mock_refresh: + tool.Parametric.refresh_post_commit(MagicMock(bl_idname=finish_op_idname)) + mock_refresh.assert_called_once() + + +def test_refresh_post_commit_skips_header_refresh_for_non_finish_operators(): + """Other operators must not trigger the header refresh. The refresh + reads ``bpy.context``; for commits invoked from a stripped operator + context (e.g. nested ``bpy.ops`` calls during project setup) this + would raise ``AttributeError`` and break the outer operator chain.""" + import bonsai.bim.handler as handler + from bonsai import tool + + with patch.object(handler, "refresh_bim_tool_headers") as mock_refresh: + tool.Parametric.refresh_post_commit(MagicMock(bl_idname="bim.append_library_element")) + mock_refresh.assert_not_called() + + +def test_geom_generation_invalidates_wall_geom_cache(): + """Bumping the generation must cause ``_get_wall_geom_cached`` to drop its + stored entries on the next read, even when the same gizmo group instance + and the same wall object are reused (the case Blender's + ``GizmoGroup.refresh()`` does not cover).""" + from bonsai import tool + from bonsai.bim.module.model import wall as wall_mod + + class _FakeGroup: + pass + + group = _FakeGroup() + fake_obj = types.SimpleNamespace(name="Wall/W001") + sentinel_a = {"length": 1.0, "height": 2.0, "x_angle": 0.0} + sentinel_b = {"length": 1.5, "height": 2.5, "x_angle": 0.0} + + with patch.object(tool.Wall, "read_geometry", side_effect=[sentinel_a, sentinel_b]): + first = wall_mod._get_wall_geom_cached(group, fake_obj) + assert first is sentinel_a + # Same call without a generation bump must hit the cache (no extra read). + assert wall_mod._get_wall_geom_cached(group, fake_obj) is sentinel_a + # Simulate an IFC commit: generation advances, cache must drop. + tool.Parametric._geom_generation += 1 + second = wall_mod._get_wall_geom_cached(group, fake_obj) + assert second is sentinel_b + assert second is not first diff --git a/src/bonsai/test/bim/module/model/test_wall_offset_gizmos.py b/src/bonsai/test/bim/module/model/test_wall_offset_gizmos.py new file mode 100644 index 0000000000..f1f41b456a --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_wall_offset_gizmos.py @@ -0,0 +1,453 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Offset arithmetic for the filling (Door / Window) wall-offset dimension gizmos. + +The apply path (``_set_offset``) must translate ``obj.matrix_world`` so the +corresponding read path (``_get_offset``) reads back the new value — i.e. +drag a left-offset to 2.0 m, then reading the left offset must return ~2.0 m. +The tests below pin that round-trip, the rotated/flipped filling case (the +add-opening flow may 180° a filling onto the wall's opposite face), and the +visibility predicate.""" + +from math import pi +from unittest import mock + +import pytest +from mathutils import Matrix + +pytestmark = pytest.mark.model + +# Module under test, plus its cache dict so each test starts from a clean slate. +import bonsai.bim.module.model.wall_offset_gizmos as subject + + +@pytest.fixture(autouse=True) +def _clear_geom_cache(): + """Per-test isolation — the module-level cache would otherwise carry mocks + across tests and surface as flaky-looking failures.""" + subject._GEOM_CACHE.clear() + yield + subject._GEOM_CACHE.clear() + + +def _make_props(filling_matrix, overall_width=1.0, overall_height=2.0, name="FillingObj"): + """Filling PropertyGroup stand-in (``BIMDoorProperties`` / + ``BIMWindowProperties`` share the relevant shape). The wall-offset helpers + only touch ``id_data`` (the filling obj), ``overall_width``, and + ``overall_height``; nothing else from the real PropertyGroup matters here.""" + filling_obj = mock.Mock(name=name) + filling_obj.name = name + filling_obj.matrix_world = filling_matrix + props = mock.Mock(spec=["id_data", "overall_width", "overall_height"]) + props.id_data = filling_obj + props.overall_width = overall_width + props.overall_height = overall_height + return props, filling_obj + + +def _patch_host_wall(wall_matrix, length, height, geom_gen=1, host_present=True, x_angle=0.0): + """Mock the chain ``Ifc.get_entity → Spatial.get_host_wall → Ifc.get_object`` + and the ``tool.Wall.*`` IFC reads so the helpers see a host wall + positioned at ``wall_matrix`` with the supplied length, height, and + extrusion angle. The IFC axis is taken to start at wall-local X=0 and + extend to X=``length``. ``host_present=False`` makes the chain return + None partway through.""" + wall_obj = mock.Mock(name="WallObj") + wall_obj.matrix_world = wall_matrix + host_wall = mock.Mock(name="IfcWall") if host_present else None + return mock.patch.multiple( + subject.tool, + Ifc=mock.MagicMock( + spec=subject.tool.Ifc, + get_entity=mock.Mock(return_value=mock.Mock(name="IfcDoor")), + get_object=mock.Mock(return_value=wall_obj if host_present else None), + ), + Spatial=mock.MagicMock( + spec=subject.tool.Spatial, + get_host_wall=mock.Mock(return_value=host_wall), + ), + Wall=mock.MagicMock( + spec=subject.tool.Wall, + get_length_and_height=mock.Mock(return_value=(length, height) if host_present else None), + get_axis_local_extent=mock.Mock(return_value=(0.0, length) if host_present else None), + get_x_angle=mock.Mock(return_value=x_angle if host_present else None), + ), + Parametric=mock.MagicMock( + spec=subject.tool.Parametric, + get_geom_generation=mock.Mock(return_value=geom_gen), + ), + ) + + +# ---------------------------------------------------------------------- +# Visibility predicate +# ---------------------------------------------------------------------- + + +def test_has_host_wall_returns_true_when_chain_resolves(): + props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5))) + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0): + assert subject.has_host_wall(props) is True + + +def test_has_host_wall_returns_false_when_no_host(): + props, _ = _make_props(Matrix.Identity(4)) + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, host_present=False): + assert subject.has_host_wall(props) is False + + +def test_has_host_wall_returns_true_for_slanted_wall(): + """Slanted LAYER2 walls keep ``matrix_world`` upright — the slope lives in + the IFC extrusion direction and in the wall mesh vertices, not in the + object transform. So wall-local Z still equals world Z, the offset math + round-trips, and the gizmos must remain visible.""" + import math + + props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5))) + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, x_angle=math.radians(15)): + assert subject.has_host_wall(props) is True + + +# ---------------------------------------------------------------------- +# Compute helpers (un-flipped filling, wall at world origin) +# ---------------------------------------------------------------------- + + +def test_get_wall_offset_left_for_filling_at_known_x(): + """Wall span 0→5 on X; filling origin at wall-local X=1.5 with +X aligned. + Filling's left edge is at wall-X 1.5 → offset_left = 1.5.""" + props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5))) + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0): + assert subject._get_offset(props, subject._LEFT) == pytest.approx(1.5) + + +def test_get_wall_offset_right_complements_left_plus_width(): + """offset_left + overall_width + offset_right == wall length.""" + props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5))) + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0): + left = subject._get_offset(props, subject._LEFT) + right = subject._get_offset(props, subject._RIGHT) + assert left + props.overall_width + right == pytest.approx(5.0) + + +def test_get_wall_offset_bottom_for_filling_at_sill_height(): + """Wall base at world Z=0; filling origin at wall-local Z=0.5 → sill at 0.5 m.""" + props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5))) + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0): + assert subject._get_offset(props, subject._BOTTOM) == pytest.approx(0.5) + + +def test_get_wall_offset_top_complements_bottom_plus_height(): + """offset_bottom + overall_height + offset_top == wall height.""" + props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5))) + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0): + bottom = subject._get_offset(props, subject._BOTTOM) + top = subject._get_offset(props, subject._TOP) + assert bottom + props.overall_height + top == pytest.approx(3.0) + + +# ---------------------------------------------------------------------- +# Slanted wall (LAYER2): wall ``matrix_world`` stays upright, so the +# offset math is the same as for a vertical wall. Pinning this guards +# against the visibility gate being re-added or the math diverging. +# ---------------------------------------------------------------------- + + +def test_get_wall_offset_bottom_for_slanted_wall(): + """For a LAYER2 slanted wall the wall matrix is identity rotation — sill + height read in the wall's local Z is still the world Z above the wall + base.""" + import math + + props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.9))) + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, x_angle=math.radians(15)): + assert subject._get_offset(props, subject._BOTTOM) == pytest.approx(0.9) + + +def test_set_wall_offset_bottom_round_trips_for_slanted_wall(): + """Round-trip on a slanted wall: setting then reading the bottom offset + yields the input. The apply path translates along the wall's local Z + direction in world space (``matrix_world.to_3x3().col[2]``), which equals + world Z for an upright wall matrix regardless of IFC slope.""" + import math + + props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5))) + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, x_angle=math.radians(15)): + subject._set_offset(props, subject._BOTTOM, 1.2) + assert subject._get_offset(props, subject._BOTTOM) == pytest.approx(1.2) + + +# ---------------------------------------------------------------------- +# Flipped filling (180° around Z) — add-opening flow flips a filling that +# lands on the wall's opposite face. Offset arithmetic must still report +# the leftmost/rightmost edges in wall coordinates, not in filling coordinates. +# ---------------------------------------------------------------------- + + +def test_get_wall_offset_left_handles_flipped_filling(): + """Flipped filling at wall-local X=1.5: filling extends from X=1.5 in filling's +X + direction, which is wall's -X. So filling's leftmost edge in wall coords is + at wall-X 0.5, not wall-X 1.5.""" + filling_matrix = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi, 4, "Z") + props, _ = _make_props(filling_matrix, overall_width=1.0) + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0): + assert subject._get_offset(props, subject._LEFT) == pytest.approx(0.5) + + +def test_get_wall_offset_right_handles_flipped_filling(): + """Same flipped filling — filling's rightmost edge in wall coords is at the + filling origin (wall-X 1.5), so offset_right = wall_length - 1.5 = 3.5.""" + filling_matrix = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi, 4, "Z") + props, _ = _make_props(filling_matrix, overall_width=1.0) + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0): + assert subject._get_offset(props, subject._RIGHT) == pytest.approx(3.5) + + +# ---------------------------------------------------------------------- +# Apply helpers — must round-trip with the compute helpers. +# ---------------------------------------------------------------------- + + +def test_set_wall_offset_left_translates_filling_along_wall_x(): + """Setting left-offset to 2.0 (from 1.5) shifts the filling origin by +0.5 + along the wall's local X axis.""" + props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5))) + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0): + subject._set_offset(props, subject._LEFT, 2.0) + assert filling_obj.matrix_world.translation.x == pytest.approx(2.0) + assert filling_obj.matrix_world.translation.z == pytest.approx(0.5) + + +def test_set_wall_offset_bottom_translates_filling_along_wall_z(): + """Setting bottom-offset to 1.0 (from 0.5) shifts the filling origin by +0.5 + along the wall's local Z axis.""" + props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5))) + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0): + subject._set_offset(props, subject._BOTTOM, 1.0) + assert filling_obj.matrix_world.translation.z == pytest.approx(1.0) + assert filling_obj.matrix_world.translation.x == pytest.approx(1.5) + + +def test_set_wall_offset_right_round_trips_with_get(): + """The right-edge setter is the symmetric pair of the left-edge setter — + they must produce mutually consistent geometry, otherwise pulling the + right edge would silently desync the left.""" + props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5))) + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0): + subject._set_offset(props, subject._RIGHT, 1.0) + result = subject._get_offset(props, subject._RIGHT) + assert result == pytest.approx(1.0) + + +def test_set_wall_offset_top_round_trips_with_get(): + """Same round-trip invariant for the top edge.""" + props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5))) + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0): + subject._set_offset(props, subject._TOP, 0.25) + result = subject._get_offset(props, subject._TOP) + assert result == pytest.approx(0.25) + + +# ---------------------------------------------------------------------- +# Cache invalidation +# ---------------------------------------------------------------------- + + +def test_geom_cache_invalidates_when_generation_bumps(): + """The cache must reset when ``tool.Parametric.get_geom_generation()`` + advances so IFC mutations don't leave stale host-wall reads in memory.""" + props, _ = _make_props(Matrix.Translation((1.0, 0.0, 0.0))) + # Patch get_geom_generation on the real class — the cache binds to the class + # at definition time, so a mock.patch.multiple on subject.tool.Parametric + # would be invisible to it. + from bonsai.tool.parametric import Parametric + + with mock.patch.object(Parametric, "get_geom_generation", return_value=1): + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0): + first = subject._get_offset(props, subject._LEFT) + with mock.patch.object(Parametric, "get_geom_generation", return_value=2): + with _patch_host_wall(Matrix.Identity(4), length=8.0, height=3.0): + right_after_bump = subject._get_offset(props, subject._RIGHT) + assert first == pytest.approx(1.0) + # 8 m wall, filling at x=1, width 1 → right offset = 6. Reads 6 only if the + # cache dropped on the generation bump. + assert right_after_bump == pytest.approx(6.0) + + +# ---------------------------------------------------------------------- +# Signed value the gizmo reports for left/right (sign-flip on filling's 180° Z rotation) +# +# Without the sign flip the dim arrow renders in the wrong world direction +# for a filling whose local +X is opposite the wall's local +X. The gizmo +# system flips its rendered dim arrow 180° around Z whenever the reported +# value is negative, so the unflipped/flipped cases produce mirrored signs +# and the arrow ends up pointing the right way visually in both orientations. +# ---------------------------------------------------------------------- + + +def test_left_signed_value_positive_for_unflipped_filling(): + props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5))) + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0): + assert subject._compute_value(props, subject._LEFT) == pytest.approx(1.5) + + +def test_left_signed_value_negative_for_flipped_filling(): + """Filling rotated 180° around Z: its leftmost edge (in wall coords) sits + at wall-X 0.5, so the user-facing left offset is 0.5 — but the signed + value must be -0.5 so the gizmo flips its rendered arrow 180° around Z.""" + from math import pi + + filling = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi, 4, "Z") + props, _ = _make_props(filling, overall_width=1.0) + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0): + assert subject._compute_value(props, subject._LEFT) == pytest.approx(-0.5) + + +def test_right_signed_value_positive_for_unflipped_filling(): + props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5))) + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0): + assert subject._compute_value(props, subject._RIGHT) == pytest.approx(2.5) + + +def test_right_signed_value_negative_for_flipped_filling(): + from math import pi + + filling = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi, 4, "Z") + props, _ = _make_props(filling, overall_width=1.0) + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0): + assert subject._compute_value(props, subject._RIGHT) == pytest.approx(-3.5) + + +# ---------------------------------------------------------------------- +# Matrix-position anchors at the wall edge (visual: arrow tail at wall, +# head at filling). The position is in filling-local frame so that mw @ pos +# lands at the wall edge in world. +# ---------------------------------------------------------------------- + + +def test_left_offset_position_lands_at_wall_start_in_world(): + """For an unflipped filling at wall-local (1.5, 0, 0.5) with the wall at the + world origin (bound_box.min_x=0), the matrix_position transformed by the + filling's world matrix must land at world (0, 0, mid_height).""" + props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)), overall_height=2.0) + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0): + pos_filling_local = subject._edge_position(props, subject._LEFT) + pos_world = filling_obj.matrix_world @ pos_filling_local + # Wall's start at world X=0 (bound_box.min_x=0 in the patched wall). + assert pos_world.x == pytest.approx(0.0) + + +def test_top_offset_position_lands_at_wall_top_in_world(): + """The top arrow anchors at wall-top — filling-local Z must equal + ``overall_height + top_offset`` so the mw-multiplied point lands on the + wall's top edge at world height.""" + props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)), overall_height=2.0) + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0): + pos_filling_local = subject._edge_position(props, subject._TOP) + pos_world = filling_obj.matrix_world @ pos_filling_local + # Wall height 3.0, wall base at world Z=0 → wall top at world Z=3. + assert pos_world.z == pytest.approx(3.0) + + +def test_bottom_offset_position_lands_at_wall_base_in_world(): + """Same idea for the bottom anchor — filling-local Z = ``-bottom_offset`` + so the point lands at world Z=0 (the wall's base).""" + props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)), overall_height=2.0) + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0): + pos_filling_local = subject._edge_position(props, subject._BOTTOM) + pos_world = filling_obj.matrix_world @ pos_filling_local + assert pos_world.z == pytest.approx(0.0) + + +def test_apply_value_takes_absolute_value(): + """Apply lambdas use ``abs(v)`` so the apply path stays correct even when + the compute side returned a negative signed value (flipped filling).""" + props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5))) + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0): + # Simulate the gizmo handing back a negative value (flipped-filling scenario). + # The user-facing offset is +2.0, the filling must end up at wall-X=2.0. + left_cfg = next(c for c in subject.WALL_OFFSET_GIZMO_CONFIGS if c.attr_name == "host_wall_offset_left") + left_cfg.apply_value(props, -2.0) + assert filling_obj.matrix_world.translation.x == pytest.approx(2.0) + + +def test_clear_caches_drops_all_entries(): + """The ``load_post`` handler calls ``clear_caches`` so a fresh file + doesn't inherit stale entries from the previous one. Pin the contract.""" + props, _ = _make_props(Matrix.Translation((1.0, 0.0, 0.0))) + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0): + subject._get_offset(props, subject._LEFT) + assert subject._GEOM_CACHE._data + subject.clear_caches() + assert not subject._GEOM_CACHE._data + + +# ---------------------------------------------------------------------- +# Stale-cache edge cases — cache keys are Blender object names, invalidated +# only by parametric generation bumps and ``load_post``. Anything that +# changes scene state without bumping the generation (Blender rename, +# external Python script deleting a wall) leaves the cache holding stale +# entries until the next IFC mutation. These tests pin that behavior. +# ---------------------------------------------------------------------- + + +def test_filling_rename_within_session_reads_correctly_under_new_name(): + """Reading offsets after a Blender rename hits a cache miss under the + new name and recomputes — the old-name entry is leaked but harmless, + and the new-name read returns correct geometry.""" + props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)), name="Door1") + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0): + first = subject._get_offset(props, subject._LEFT) + assert "Door1" in subject._GEOM_CACHE._data + filling_obj.name = "Door1_renamed" + second = subject._get_offset(props, subject._LEFT) + assert first == pytest.approx(1.5) + assert second == pytest.approx(1.5) + assert "Door1_renamed" in subject._GEOM_CACHE._data + + +def test_host_wall_deletion_serves_stale_cache_until_invalidation(): + """If the host wall is removed without bumping the generation counter + (e.g. external script), the cache keeps returning the pre-deletion + geometry — only an IFC mutation or ``clear_caches()`` drops the stale + entry.""" + props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5))) + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0): + assert subject.has_host_wall(props) is True + # Even after the patch exits and the chain would now return None, the + # cached entry under the filling's name is still served. + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, host_present=False): + assert subject.has_host_wall(props) is True # stale + subject.clear_caches() + assert subject.has_host_wall(props) is False # recomputed + + +def test_filling_rotated_90_degrees_in_wall_plane_falls_back_to_positive_sign(): + """A filling rotated exactly 90° around Z has ``col[0].x == 0.0``, which + is the ambiguous boundary for the X-sign. The implementation falls back + to +1 (the ``>= 0.0`` branch), so the renderer-side value reads as + positive — same sign as an unflipped filling.""" + filling_matrix = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi / 2, 4, "Z") + props, _ = _make_props(filling_matrix, overall_width=1.0) + with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0): + signed = subject._compute_value(props, subject._LEFT) + # +1 fallback × unflipped-equivalent left offset = +1.5 (filling origin in wall coords). + assert signed == pytest.approx(1.5) diff --git a/src/bonsai/test/bim/module/model/test_wall_preview_mesh.py b/src/bonsai/test/bim/module/model/test_wall_preview_mesh.py new file mode 100644 index 0000000000..25c297ec9c --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_wall_preview_mesh.py @@ -0,0 +1,73 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Pins the outward-normals invariant of the parametric-wall draft preview mesh. + +``regenerate_wall_mesh_from_props`` rebuilds ``obj.data`` as a fresh bmesh +box from ``BIMWallProperties`` every time a gizmo handle moves. The hand +authored face windings carry no guarantee of outward orientation, so the +function must normalise face windings before writing the mesh back — +otherwise the viewport renders the draft with inverted shading and +back-face culling hides faces the user expects to see.""" + +import types +from unittest.mock import patch + +import bpy +import pytest +from mathutils import Vector + +pytestmark = pytest.mark.wall + + +def test_regenerate_wall_mesh_from_props_outward_normals(): + """Every face of the preview box must have its normal pointing away + from the box centroid — the contract every other preview-mesh builder + in ``bim/module/model`` (door / window / roof / railing) holds.""" + from bonsai.bim.module.model.wall import regenerate_wall_mesh_from_props + + mesh = bpy.data.meshes.new("preview_mesh") + obj = bpy.data.objects.new("preview_wall", mesh) + fake_props = types.SimpleNamespace( + length=2.0, + height=3.0, + thickness=0.2, + offset=0.0, + x_angle=0.0, + anchor_x=0.0, + mesh_dirty=False, + ) + + try: + with patch("bonsai.tool.Model.get_wall_props", return_value=fake_props): + regenerate_wall_mesh_from_props(obj) + + assert len(mesh.polygons) == 6, f"expected 6 faces, got {len(mesh.polygons)}" + centroid = sum((v.co for v in mesh.vertices), Vector()) / len(mesh.vertices) + for face in mesh.polygons: + outward = (face.center - centroid).normalized() + dot = face.normal.dot(outward) + assert dot > 0.5, ( + f"face {face.index} normal {tuple(face.normal)} points inward " + f"(outward direction {tuple(outward)}, dot={dot:.3f})" + ) + finally: + bpy.data.objects.remove(obj) + bpy.data.meshes.remove(mesh) diff --git a/src/bonsai/test/bim/module/model/test_wall_split_openings.py b/src/bonsai/test/bim/module/model/test_wall_split_openings.py new file mode 100644 index 0000000000..b94b28ca9c --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_wall_split_openings.py @@ -0,0 +1,377 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Regression tests for wall-split opening assignment when the cut passes +through an opening. + +Bug repro before the fix: when ``bpy.ops.bim.split_wall`` (Shift+K) cut a wall +through an opening, ``DumbWallJoiner.split`` decided opening assignment using +the opening's centre-point projected onto the wall axis. Any opening whose +extent straddled the cut was therefore assigned to whichever side its centre +sat on, leaving the neighbour wall with no void where the opening overlapped. + +The fix replaces the single-point test with an axis-projected extent +(``_opening_axis_extent``): an opening is removed from a wall only when its +extent lies *entirely* outside that wall's portion of the axis. Straddling +openings stay on both walls.""" + +from unittest.mock import MagicMock, patch + +import bpy +import pytest + +pytestmark = pytest.mark.wall + + +def _fake_shape(verts_local, matrix_world_4x4): + """Build a stand-in for the ``shape`` object returned by + ``ifcopenshell.geom.create_shape``. ``get_vertices`` and + ``get_shape_matrix`` are mocked separately to read off this stand-in.""" + import numpy as np + + shape = MagicMock(name="shape") + shape.geometry = MagicMock(name="geometry") + shape._verts = np.asarray(verts_local, dtype=np.float64) + shape._matrix = np.asarray(matrix_world_4x4, dtype=np.float64) + return shape + + +def test_opening_axis_extent_uses_geometry_kernel_vertices(): + """``_opening_axis_extent`` drives ``ifcopenshell.geom.create_shape`` to + get the opening's real geometry vertices and ``get_shape_matrix`` to get + its world placement, then projects the world-space corners onto the wall + axis. This is the production path — works for every representation type + Bonsai may produce (mapped representation, swept area, brep, boolean). + + A unit cube centred at world X=5 on a 10m wall axis projects to + t ∈ [0.45, 0.55] (the cube spans 0.5m on each axis around the centre).""" + from bonsai.bim.module.model.wall import _opening_axis_extent + + # Unit cube in local coords, centred at (0,0,0), extent ±0.5. + verts_local = [ + (-0.5, -0.5, -0.5), + (0.5, -0.5, -0.5), + (0.5, 0.5, -0.5), + (-0.5, 0.5, -0.5), + (-0.5, -0.5, 0.5), + (0.5, -0.5, 0.5), + (0.5, 0.5, 0.5), + (-0.5, 0.5, 0.5), + ] + matrix_world = [ + [1.0, 0.0, 0.0, 5.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + fake_shape = _fake_shape(verts_local, matrix_world) + opening = MagicMock(name="opening") + axis_reference = ( + __import__("mathutils").Vector((0.0, 0.0)), + __import__("mathutils").Vector((10.0, 0.0)), + ) + + with ( + patch("ifcopenshell.geom.create_shape", return_value=fake_shape), + patch("ifcopenshell.util.shape.get_vertices", return_value=fake_shape._verts), + patch("ifcopenshell.util.shape.get_shape_matrix", return_value=fake_shape._matrix), + ): + min_t, max_t = _opening_axis_extent(opening, axis_reference, unit_scale=1.0) + + # Cube spans world X ∈ [4.5, 5.5] → t ∈ [0.45, 0.55]. + assert min_t == pytest.approx(0.45) + assert max_t == pytest.approx(0.55) + + +def test_opening_axis_extent_falls_back_to_placement_when_geometry_kernel_fails(): + """When ``ifcopenshell.geom.create_shape`` raises (representation it + can't process), the helper falls back to a degenerate single-point range + at the opening's composed placement origin. This is the safety net — it + matches the pre-fix center-only semantics rather than dropping the + opening entirely.""" + from bonsai.bim.module.model.wall import _opening_axis_extent + + opening = MagicMock(name="opening") + placement_matrix = [ + [1.0, 0.0, 0.0, 5.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + axis_reference = ( + __import__("mathutils").Vector((0.0, 0.0)), + __import__("mathutils").Vector((10.0, 0.0)), + ) + + with ( + patch("ifcopenshell.geom.create_shape", side_effect=RuntimeError("kernel failure")), + patch("ifcopenshell.util.placement.get_local_placement") as mock_get_placement, + ): + mock_get_placement.return_value = type("FakeArr", (), {"tolist": lambda self: placement_matrix})() + min_t, max_t = _opening_axis_extent(opening, axis_reference, unit_scale=1.0) + + assert min_t == max_t == pytest.approx(0.5) + + +def test_opening_axis_extent_offset_cursor_inside_extent_returns_straddling_range(): + """The regression guard for the user-reported bug across two fix attempts: + when the cursor is placed *inside* the opening but not at its exact + centre, the helper must still return a range that straddles the cursor + position so the side test keeps the opening on both walls. + + Pre-fix v2/v3 collapsed to a degenerate range whenever the production + representation type wasn't recognised (Blender bound_box absent in v2; + mapped representation not walked in v3). The current implementation uses + ``ifcopenshell.geom.create_shape``, which handles every representation + Bonsai may produce.""" + from bonsai.bim.module.model.wall import _opening_axis_extent + + # 2m-wide opening centred at world X=5 → world X ∈ [4.0, 6.0] → t ∈ [0.4, 0.6]. + verts_local = [(-1.0, -0.5, -0.5), (1.0, 0.5, 0.5)] + matrix_world = [ + [1.0, 0.0, 0.0, 5.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + fake_shape = _fake_shape(verts_local, matrix_world) + opening = MagicMock(name="opening") + axis_reference = ( + __import__("mathutils").Vector((0.0, 0.0)), + __import__("mathutils").Vector((10.0, 0.0)), + ) + + with ( + patch("ifcopenshell.geom.create_shape", return_value=fake_shape), + patch("ifcopenshell.util.shape.get_vertices", return_value=fake_shape._verts), + patch("ifcopenshell.util.shape.get_shape_matrix", return_value=fake_shape._matrix), + ): + min_t, max_t = _opening_axis_extent(opening, axis_reference, unit_scale=1.0) + + # Cursor at world X=4.7 → t=0.47 (inside the opening, not centred on it). + cut_percentage = 0.47 + assert ( + min_t < cut_percentage < max_t + ), f"opening [t={min_t}, t={max_t}] must straddle off-centre cursor at t={cut_percentage}" + + +def test_straddling_opening_is_kept_on_both_sides(): + """The pruning logic must keep an opening whose extent straddles the cut + on *both* element1 and element2. + + Before the fix, an opening with centre at t=0.5 and cut_percentage=0.6 + would be removed from element2 (centre < cut) but kept on element1; the + opening's right half — which physically overlaps element2 — would be + silently dropped. After the fix, the opening overlaps both portions of + the axis (min_t=0.3 < 0.6 < max_t=0.7) so both walls keep it. + + Replays the boolean comparisons that ``DumbWallJoiner.split`` performs on + the helper's return value; does not call the helper itself.""" + min_t, max_t = 0.3, 0.7 # straddles any cut_percentage in (0.3, 0.7) + cut_percentage = 0.6 + + removed_from_element1 = min_t > cut_percentage + removed_from_element2 = max_t < cut_percentage + + assert removed_from_element1 is False, "straddling opening must remain on element1" + assert removed_from_element2 is False, "straddling opening must remain on element2" + + +def test_opening_entirely_past_cut_is_removed_from_element1_only(): + """Opening lies wholly on element2's side (min_t > cut_percentage). + Pre-fix and post-fix both remove it from element1; post-fix additionally + guarantees it stays on element2 because max_t > cut_percentage.""" + min_t, max_t = 0.7, 0.9 + cut_percentage = 0.5 + + assert (min_t > cut_percentage) is True # removed from element1 + assert (max_t < cut_percentage) is False # kept on element2 + + +def test_opening_entirely_before_cut_is_removed_from_element2_only(): + """Mirror of the above: opening wholly on element1's side.""" + min_t, max_t = 0.1, 0.3 + cut_percentage = 0.5 + + assert (min_t > cut_percentage) is False # kept on element1 + assert (max_t < cut_percentage) is True # removed from element2 + + +def test_opening_touching_cut_at_boundary_stays_on_both_walls(): + """Boundary touch: an opening's ``max_t`` lands exactly on the cut. Strict + inequalities keep the opening on both walls — the safer default. (Non- + strict ``<=`` would have removed from element2 instead.)""" + min_t, max_t = 0.2, 0.5 + cut_percentage = 0.5 + + assert (min_t > cut_percentage) is False # kept on element1 + assert (max_t < cut_percentage) is False # kept on element2 (boundary == cut) + + +def test_degenerate_range_at_cut_keeps_opening_on_both_walls(): + """Regression guard for the **post-fix-v1 regression**: when the helper + falls back to a degenerate range ``(t, t)`` (geometry kernel failed, or + the pre-create_shape fix attempts that produced only the placement + centre), placing the 3D cursor *on* the opening's centre makes + ``cut_percentage == t``. + + With non-strict ``>=`` / ``<=`` tests, the degenerate range matched both + removal conditions and both walls dropped the opening — leaving the user + with two walls and no hole anywhere. Strict ``>`` / ``<`` tests keep the + opening on both walls in this case, which matches the visible geometry.""" + min_t, max_t = 0.5, 0.5 # degenerate range — both bounds at the centre + cut_percentage = 0.5 # cursor placed exactly on the opening centre + + removed_from_element1 = min_t > cut_percentage + removed_from_element2 = max_t < cut_percentage + + assert removed_from_element1 is False, "must not remove from element1 when cursor sits on opening centre" + assert removed_from_element2 is False, "must not remove from element2 when cursor sits on opening centre" + + +# --------------------------------------------------------------------------- +# Filled-opening void-straddle behaviour +# +# When a wall split passes through a door/window, the filling (the door +# element itself) belongs to whichever wall contains its centre — but the +# void cut by the IfcOpeningElement may still straddle the cut, in which +# case the neighbour wall's body must also be cut. The helper that adds the +# pure-void copy is ``_add_void_copy``; the decision is taken in +# ``DumbWallJoiner.split``'s filled-opening loop. +# --------------------------------------------------------------------------- + + +def _make_void_copy_mock(has_filling_rel=True): + """Build the ``void_copy`` MagicMock returned by ``copy_class`` so its + ``HasFillings`` / ``VoidsElements`` / ``ObjectPlacement`` shape matches + what ``_add_void_copy`` mutates.""" + copy_placement = MagicMock(name="copy_placement") + copy_placement.is_a = lambda klass: klass == "IfcLocalPlacement" + void_relation = MagicMock(name="VoidsRelation") + void_copy = MagicMock(name="void_copy") + void_copy.HasFillings = (MagicMock(name="copy_filling_rel"),) if has_filling_rel else () + void_copy.VoidsElements = (void_relation,) + void_copy.ObjectPlacement = copy_placement + return void_copy, void_relation, copy_placement + + +def test_add_void_copy_strips_fillings_and_reparents_to_target_wall(): + """``_add_void_copy`` must create a pure-void IfcOpeningElement attached + to the target wall: the filling relationship copied along with the source + must be removed, ``VoidsElements[0].RelatingBuildingElement`` must point + at the target wall, and the representation must be a deep copy (not a + shared reference with the source).""" + from bonsai.bim.module.model.wall import _add_void_copy + + source_representation = MagicMock(name="source_representation") + source_opening = MagicMock(name="source_opening") + source_opening.Representation = source_representation + + void_copy, void_relation, copy_placement = _make_void_copy_mock() + carried_filling_rel = void_copy.HasFillings[0] + + target_placement = MagicMock(name="target_placement") + target_wall = MagicMock(name="target_wall") + target_wall.ObjectPlacement = target_placement + + ifc_file = MagicMock(name="ifc_file") + deep_copy_result = MagicMock(name="copied_representation") + + with ( + patch("bonsai.tool.Ifc.get", return_value=ifc_file), + patch("ifcopenshell.api.root.copy_class", return_value=void_copy) as mock_copy_class, + patch("ifcopenshell.util.element.copy_deep", return_value=deep_copy_result), + ): + _add_void_copy(target_wall, source_opening) + + # The carried-over filling relationship must be removed — the copy is a pure void. + ifc_file.remove.assert_called_once_with(carried_filling_rel) + # The void now points at the target wall, not the source's wall. + assert void_relation.RelatingBuildingElement is target_wall + # The placement is reparented under the target wall's local placement. + assert copy_placement.PlacementRelTo is target_placement + # The representation is deep-copied so future edits don't ripple back to source. + assert void_copy.Representation is deep_copy_result + mock_copy_class.assert_called_once_with(ifc_file, product=source_opening) + + +def test_add_void_copy_handles_source_with_no_fillings(): + """If the source opening has no ``HasFillings`` (the copy_class result + inherits that), the loop over ``void_copy.HasFillings or ()`` must run + zero times — no spurious ``ifc_file.remove`` call.""" + from bonsai.bim.module.model.wall import _add_void_copy + + source_opening = MagicMock(name="source_opening") + source_opening.Representation = MagicMock(name="rep") + + void_copy, _void_relation, _copy_placement = _make_void_copy_mock(has_filling_rel=False) + target_wall = MagicMock(name="target_wall") + + ifc_file = MagicMock(name="ifc_file") + + with ( + patch("bonsai.tool.Ifc.get", return_value=ifc_file), + patch("ifcopenshell.api.root.copy_class", return_value=void_copy), + patch("ifcopenshell.util.element.copy_deep", return_value=MagicMock()), + ): + _add_void_copy(target_wall, source_opening) + + ifc_file.remove.assert_not_called() + + +def test_filled_opening_void_straddle_decision_keeps_void_on_neighbour(): + """Replays the decision logic in ``DumbWallJoiner.split``'s filled-opening + loop for the case ``filling_position <= cut_percentage and void_straddles``: + filling stays on element1 (its centre is before the cut), but the void + extent crosses the cut, so the neighbour wall (element2) must receive a + pure-void copy via ``_add_void_copy``. + + Mirrors the unfilled-opening decision tests — exercises the boolean + branching rather than full ``split()`` integration.""" + cut_percentage = 0.5 + filling_position = 0.4 # filling centre on element1's side + min_t, max_t = 0.3, 0.7 # void extent straddles cut at 0.5 + + void_straddles = min_t < cut_percentage < max_t + filling_on_element2 = filling_position > cut_percentage + + # Expected branch: filling stays, but void straddles → add copy to element2. + assert void_straddles is True + assert filling_on_element2 is False + # Equivalent to the ``elif void_straddles:`` path adding a void copy to element2. + + +def test_filled_opening_void_straddle_with_filling_on_far_side_keeps_void_on_origin(): + """The symmetric case: ``filling_position > cut_percentage and void_straddles``. + Filling moves to element2 with the original void; element1 needs a + pure-void copy back (the void's element1 portion would otherwise be + orphaned). Documents the boolean state of the inner branch.""" + cut_percentage = 0.5 + filling_position = 0.6 # filling centre on element2's side + min_t, max_t = 0.3, 0.7 + + void_straddles = min_t < cut_percentage < max_t + filling_on_element2 = filling_position > cut_percentage + + assert void_straddles is True + assert filling_on_element2 is True + # Equivalent to the outer ``if filling_position > cut_percentage`` path + # taking its inner ``if void_straddles`` branch and adding a void copy + # back to element1. diff --git a/src/bonsai/test/bim/module/model/test_wall_topology_cache.py b/src/bonsai/test/bim/module/model/test_wall_topology_cache.py new file mode 100644 index 0000000000..c22b4813f8 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_wall_topology_cache.py @@ -0,0 +1,164 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Cache-invalidation tests for the wall-topology gizmo helpers. + +``GizmoWallUnjoinSingle`` and ``GizmoWallJoinIntersection`` re-run +``_iter_path_connections``, ``_are_walls_joined``, ``_are_walls_collinear``, +and ``core.project_axis_intersection`` every viewport redraw without the +cache helpers wrapping them. These tests pin that: + +- Repeat calls within one IFC generation reuse the cached result. +- An IFC-generation bump invalidates the cache. +- ``refresh()`` (the Blender state-change hook on the mixin) drops the cache.""" + +from unittest.mock import Mock, patch + +import pytest + +pytestmark = pytest.mark.model + + +def test_get_wall_connections_cached_returns_cached_within_generation(): + from bonsai.bim.module.model import wall + + group = Mock(spec=[]) + elem = Mock() + elem.GlobalId = "0AAAAAAAAAAAAAAAAAAAAA" + expected = [(Mock(), "ATEND", "ATSTART")] + + call_count = {"n": 0} + + def counting_iter(e): + call_count["n"] += 1 + return expected + + with patch.object(wall, "_iter_path_connections", side_effect=counting_iter), patch( + "bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", return_value=7 + ): + first = wall._get_wall_connections_cached(group, elem) + second = wall._get_wall_connections_cached(group, elem) + + assert first is second + assert call_count["n"] == 1 + + +def test_get_wall_connections_cached_invalidates_on_generation_bump(): + from bonsai.bim.module.model import wall + + group = Mock(spec=[]) + elem = Mock() + elem.GlobalId = "0AAAAAAAAAAAAAAAAAAAAA" + + call_count = {"n": 0} + + def counting_iter(e): + call_count["n"] += 1 + return [] + + gen_state = {"gen": 1} + with patch.object(wall, "_iter_path_connections", side_effect=counting_iter), patch( + "bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", side_effect=lambda: gen_state["gen"] + ): + wall._get_wall_connections_cached(group, elem) + gen_state["gen"] = 2 + wall._get_wall_connections_cached(group, elem) + + assert call_count["n"] == 2 + + +def test_get_wall_pair_predicate_cached_reuses_value_within_generation(): + from bonsai.bim.module.model import wall + + group = Mock(spec=[]) + call_count = {"n": 0} + + def compute(): + call_count["n"] += 1 + return "result" + + with patch("bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", return_value=3): + first = wall._get_wall_pair_predicate_cached(group, ("joined", ("guid_a", "guid_b")), compute) + second = wall._get_wall_pair_predicate_cached(group, ("joined", ("guid_a", "guid_b")), compute) + + assert first == second == "result" + assert call_count["n"] == 1 + + +def test_get_wall_pair_predicate_cached_distinguishes_predicate_kind(): + """The cache key includes a tag string ("joined" vs "collinear" vs + "intersection") so adding a second predicate for the same pair doesn't + return the first predicate's value.""" + from bonsai.bim.module.model import wall + + group = Mock(spec=[]) + pair = ("guid_a", "guid_b") + with patch("bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", return_value=3): + a = wall._get_wall_pair_predicate_cached(group, ("joined", pair), lambda: "JOINED") + b = wall._get_wall_pair_predicate_cached(group, ("collinear", pair), lambda: "COLLINEAR") + + assert a == "JOINED" + assert b == "COLLINEAR" + + +def test_get_wall_pair_predicate_cached_invalidates_on_generation_bump(): + from bonsai.bim.module.model import wall + + group = Mock(spec=[]) + call_count = {"n": 0} + + def compute(): + call_count["n"] += 1 + return call_count["n"] + + gen_state = {"gen": 1} + with patch( + "bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", side_effect=lambda: gen_state["gen"] + ): + first = wall._get_wall_pair_predicate_cached(group, ("joined", ("a", "b")), compute) + gen_state["gen"] = 2 + second = wall._get_wall_pair_predicate_cached(group, ("joined", ("a", "b")), compute) + + assert first == 1 + assert second == 2 + assert call_count["n"] == 2 + + +def test_mixin_refresh_clears_pair_and_connection_caches(): + """``refresh()`` is Blender's "state changed" signal — typically a + selection change. Both the connection list and pair predicate caches + must drop alongside the geometry cache, otherwise the next frame would + read predicates that targeted the previously-selected pair.""" + from bonsai.bim.module.model import wall + + class _Group(wall._WallGeomCachedBillboardingMixin): + def position_gizmos(self, context): + pass + + group = _Group() + group._wall_geom_cache = {"x": "geom"} + group._wall_connections_cache = {"guid": []} + group._wall_pair_predicate_cache = {"key": "value"} + + group.refresh(context=Mock()) + + assert group._wall_geom_cache is None + assert group._wall_connections_cache is None + assert group._wall_pair_predicate_cache is None diff --git a/src/bonsai/test/bim/module/project/__init__.py b/src/bonsai/test/bim/module/project/__init__.py new file mode 100644 index 0000000000..023d474feb --- /dev/null +++ b/src/bonsai/test/bim/module/project/__init__.py @@ -0,0 +1,19 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. diff --git a/src/bonsai/test/bim/module/project/test_pending_opening_cuts.py b/src/bonsai/test/bim/module/project/test_pending_opening_cuts.py new file mode 100644 index 0000000000..fc4fed91b5 --- /dev/null +++ b/src/bonsai/test/bim/module/project/test_pending_opening_cuts.py @@ -0,0 +1,108 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +from unittest.mock import patch + +import bpy +import ifcopenshell +import pytest + +import bonsai.tool as tool +from test.bim.bootstrap import NewIfc + +pytestmark = pytest.mark.project + + +def _populate_pending(*element_ids: int) -> None: + pending = tool.Project.get_project_props().pending_opening_recut + pending.clear() + for eid in element_ids: + pending.add().ifc_definition_id = eid + + +def _make_linked_wall(name: str = "Wall") -> tuple[ifcopenshell.entity_instance, bpy.types.Object]: + ifc_file = tool.Ifc.get() + element = ifc_file.create_entity("IfcWall", GlobalId=ifcopenshell.guid.new(), Name=name) + obj = bpy.data.objects.new(name, bpy.data.meshes.new(name)) + bpy.context.scene.collection.objects.link(obj) + tool.Ifc.link(element, obj) + return element, obj + + +class TestApplyPendingOpeningCuts(NewIfc): + def test_clears_pending_and_calls_reimport_with_apply_openings(self): + element, obj = _make_linked_wall() + _populate_pending(element.id()) + + with patch.object(tool.Geometry, "reimport_element_representations") as mock_reimport, patch( + "ifcopenshell.util.representation.get_representation", + return_value=object(), + ): + result = bpy.ops.bim.apply_pending_opening_cuts() + + assert result == {"FINISHED"} + assert len(tool.Project.get_project_props().pending_opening_recut) == 0 + mock_reimport.assert_called_once() + _, kwargs = mock_reimport.call_args + assert kwargs.get("apply_openings") is True + + def test_skips_entries_whose_entity_is_gone(self): + _populate_pending(99999) # ID guaranteed not present + + with patch.object(tool.Geometry, "reimport_element_representations") as mock_reimport: + result = bpy.ops.bim.apply_pending_opening_cuts() + + assert result == {"FINISHED"} + assert len(tool.Project.get_project_props().pending_opening_recut) == 0 + mock_reimport.assert_not_called() + + +class TestDismissPendingOpeningCuts(NewIfc): + def test_clears_collection_without_calling_reimport(self): + element, _obj = _make_linked_wall() + _populate_pending(element.id()) + + with patch.object(tool.Geometry, "reimport_element_representations") as mock_reimport: + result = bpy.ops.bim.dismiss_pending_opening_cuts() + + assert result == {"FINISHED"} + assert len(tool.Project.get_project_props().pending_opening_recut) == 0 + mock_reimport.assert_not_called() + + +class TestSelectPendingOpeningCuts(NewIfc): + def test_selects_objects_for_each_pending_entry(self): + e1, o1 = _make_linked_wall("WallA") + e2, o2 = _make_linked_wall("WallB") + _populate_pending(e1.id(), e2.id()) + + for obj in bpy.context.view_layer.objects: + obj.select_set(False) + + result = bpy.ops.bim.select_pending_opening_cuts() + + assert result == {"FINISHED"} + assert o1.select_get() and o2.select_get() + assert bpy.context.view_layer.objects.active in (o1, o2) + + def test_cancels_when_no_objects_match(self): + _populate_pending(99999) + result = bpy.ops.bim.select_pending_opening_cuts() + assert result == {"CANCELLED"} diff --git a/src/bonsai/test/bim/test_execute_ifc_operator_partial_state.py b/src/bonsai/test/bim/test_execute_ifc_operator_partial_state.py new file mode 100644 index 0000000000..6f95a25caf --- /dev/null +++ b/src/bonsai/test/bim/test_execute_ifc_operator_partial_state.py @@ -0,0 +1,232 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Framework contract test for the partial-state recovery hint in +``IfcStore.execute_ifc_operator``. + +The framework wraps every ``tool.Ifc.Operator._execute`` call between +``ifc_file.begin_transaction()`` and ``ifc_file.end_transaction()``. When +``_execute`` raises after at least one ``ifcopenshell.api.*`` mutation +has been captured, the user is in a partial state (IFC mutated, Blender +side stale) and the framework surfaces a WARNING naming Ctrl+Z so the +recovery path is discoverable instead of buried behind a raw traceback. + +The contract has three parts pinned here: + +1. ``ifcopenshell.file.Transaction.operations`` is a public list and is + the introspection idiom the framework relies on. +2. The WARNING fires only when ``_execute`` raised AND the transaction + captured at least one operation. +3. A successful ``_execute`` never emits the WARNING regardless of + whether IFC was mutated.""" + +from unittest import mock + +import pytest + +pytestmark = pytest.mark.misc + + +@pytest.fixture(autouse=True) +def _require_real_bpy(): + import types as _types + + import bpy + + if not isinstance(bpy, _types.ModuleType) or hasattr(bpy, "_mock_name"): + pytest.skip("requires real Blender (bpy is mocked or absent)") + + +@pytest.fixture +def fresh_ifc(): + """Set up a fresh ``ifcopenshell.file`` as ``IfcStore.file`` and tear + it down afterwards. Each test gets a virgin transaction state.""" + import ifcopenshell + + from bonsai.bim.ifc import IfcStore + + previous = IfcStore.file + previous_transaction = IfcStore.current_transaction + IfcStore.file = ifcopenshell.file(schema="IFC4") + IfcStore.current_transaction = "" + try: + yield IfcStore.file + finally: + IfcStore.file = previous + IfcStore.current_transaction = previous_transaction + + +@pytest.fixture +def neutralised_framework(): + """Patch the side-effect-heavy helpers in ``IfcStore.execute_ifc_operator`` + so a bare unit test can drive it without a populated Scene / props / + decorator handlers.""" + with mock.patch("bonsai.bim.ifc.tool.Blender.get_bim_props") as get_props, mock.patch( + "bonsai.bim.handler.refresh_ui_data" + ), mock.patch("bonsai.bim.ifc.tool.Parametric.refresh_post_commit"), mock.patch( + "bonsai.bim.ifc.IfcStore.add_transaction_operation" + ), mock.patch( + "bonsai.bim.ifc.IfcStore.begin_transaction" + ), mock.patch( + "bonsai.bim.ifc.IfcStore.end_transaction" + ), mock.patch( + "bonsai.bim.ifc.IfcStore.get_ifc_file_undo_callback", return_value=lambda data: True + ): + get_props.return_value = mock.Mock(is_dirty=False) + yield + + +def _make_operator(execute_callback): + """Build a ``Mock`` operator that satisfies the attribute reads the + framework performs (``bl_idname``, ``_execute``, ``report``, etc.).""" + op = mock.Mock(spec=["bl_idname", "_execute", "_invoke", "_modal", "report", "transaction_key"]) + op.bl_idname = "bim.test_partial_state" + op._execute = execute_callback + return op + + +def _mutate_ifc(): + """Single ``ifcopenshell.api.*`` call so the transaction captures at + least one operation. ``project.create_file`` would not work here since + it replaces the file; pick a small entity mutation that always lands.""" + import ifcopenshell.api.owner + + from bonsai.bim.ifc import IfcStore + + ifcopenshell.api.owner.add_person(IfcStore.get_file()) + + +def test_transaction_operations_is_empty_until_first_api_call(fresh_ifc): + """Pin the introspection contract the framework relies on: + ``Transaction.operations`` is empty after ``begin_transaction()`` and + populated by any ``ifcopenshell.api.*`` call.""" + fresh_ifc.begin_transaction() + assert fresh_ifc.transaction is not None + assert fresh_ifc.transaction.operations == [] + + _mutate_ifc() + + assert len(fresh_ifc.transaction.operations) > 0 + + +def test_no_mutation_no_raise_no_warning(fresh_ifc, neutralised_framework): + """Happy path: ``_execute`` does nothing, returns FINISHED. + Framework MUST NOT emit the partial-state WARNING.""" + from bonsai.bim.ifc import IfcStore + + op = _make_operator(execute_callback=lambda context: {"FINISHED"}) + IfcStore.execute_ifc_operator(op, context=mock.Mock()) + + for call in op.report.call_args_list: + assert "Ctrl+Z" not in call.args[1], "partial-state WARNING fired on a clean success path" + + +def test_raise_before_mutation_no_warning(fresh_ifc, neutralised_framework): + """``_execute`` raises before any IFC mutation. The transaction has no + operations → no partial state → no WARNING.""" + from bonsai.bim.ifc import IfcStore + + def _raise_immediately(context): + raise RuntimeError("kaboom") + + op = _make_operator(execute_callback=_raise_immediately) + with pytest.raises(RuntimeError, match="kaboom"): + IfcStore.execute_ifc_operator(op, context=mock.Mock()) + + for call in op.report.call_args_list: + assert "Ctrl+Z" not in call.args[1], "partial-state WARNING fired without any mutation" + + +def test_mutation_then_success_no_warning(fresh_ifc, neutralised_framework): + """Real mutation, normal FINISHED return. WARNING is exception-path + only and MUST NOT fire on a clean success.""" + from bonsai.bim.ifc import IfcStore + + def _mutate_and_finish(context): + _mutate_ifc() + return {"FINISHED"} + + op = _make_operator(execute_callback=_mutate_and_finish) + IfcStore.execute_ifc_operator(op, context=mock.Mock()) + + for call in op.report.call_args_list: + assert "Ctrl+Z" not in call.args[1], "partial-state WARNING fired on a successful mutation" + + +def test_mutation_then_raise_emits_warning(fresh_ifc, neutralised_framework): + """The contract this whole change exists for: mutate, then raise. + Framework MUST emit a WARNING naming Ctrl+Z before the exception + re-raises into Blender's normal operator error flow.""" + from bonsai.bim.ifc import IfcStore + + def _mutate_then_raise(context): + _mutate_ifc() + raise RuntimeError("rebuild failed after IFC mutation") + + op = _make_operator(execute_callback=_mutate_then_raise) + with pytest.raises(RuntimeError, match="rebuild failed"): + IfcStore.execute_ifc_operator(op, context=mock.Mock()) + + warning_calls = [ + call + for call in op.report.call_args_list + if call.args and call.args[0] == {"WARNING"} and "Ctrl+Z" in call.args[1] + ] + assert ( + len(warning_calls) == 1 + ), f"expected exactly one partial-state WARNING with Ctrl+Z guidance, got: {op.report.call_args_list}" + + +def test_mutation_then_raise_pushes_blender_undo_step(fresh_ifc, neutralised_framework): + """A raised operator does not get an automatic Blender undo step (same gap + as the CANCELLED-modal path). The framework pushes one explicitly so the + Ctrl+Z the WARNING advertises actually rewinds the partial mutation.""" + from bonsai.bim.ifc import IfcStore + + def _mutate_then_raise(context): + _mutate_ifc() + raise RuntimeError("rebuild failed after IFC mutation") + + op = _make_operator(execute_callback=_mutate_then_raise) + with mock.patch("bonsai.bim.ifc.bpy.ops", new=mock.Mock()) as bpy_ops: + undo_push = bpy_ops.ed.undo_push + with pytest.raises(RuntimeError, match="rebuild failed"): + IfcStore.execute_ifc_operator(op, context=mock.Mock()) + + assert undo_push.call_count == 1, f"expected exactly one undo_push, got {undo_push.call_count}" + pushed_message = undo_push.call_args.kwargs.get("message", "") + assert op.bl_idname in pushed_message, f"undo step message should name the operator, got: {pushed_message!r}" + + +def test_raise_before_mutation_does_not_push_undo_step(fresh_ifc, neutralised_framework): + """No mutation captured → nothing to recover → no recovery undo step. + Avoids polluting the undo history with no-op recovery snapshots.""" + from bonsai.bim.ifc import IfcStore + + def _raise_immediately(context): + raise RuntimeError("kaboom") + + op = _make_operator(execute_callback=_raise_immediately) + with mock.patch("bonsai.bim.ifc.bpy.ops", new=mock.Mock()) as bpy_ops: + undo_push = bpy_ops.ed.undo_push + with pytest.raises(RuntimeError, match="kaboom"): + IfcStore.execute_ifc_operator(op, context=mock.Mock()) + + assert undo_push.call_count == 0, "undo_push fired on a non-partial-state raise" diff --git a/src/bonsai/test/bim/test_feature.py b/src/bonsai/test/bim/test_feature.py index 79e8494441..39a4d7710a 100644 --- a/src/bonsai/test/bim/test_feature.py +++ b/src/bonsai/test/bim/test_feature.py @@ -15,6 +15,8 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# This file was modified with the assistance of an AI coding tool. from __future__ import annotations @@ -1131,6 +1133,17 @@ def the_variable_key_is_value(key, value): variables[key] = eval(replace_variables(value)) +@then(parsers.parse('the variable "{key}" equals "{value}"')) +def the_variable_key_equals_value(key, value): + assert key in variables, f'Variable "{key}" was never set' + expected = eval(replace_variables(value)) + actual = variables[key] + if isinstance(actual, float) and isinstance(expected, float): + assert abs(actual - expected) < 1e-5, f'Variable "{key}" is {actual!r}, expected {expected!r}' + else: + assert actual == expected, f'Variable "{key}" is {actual!r}, expected {expected!r}' + + @then("nothing happens") def nothing_happens(): pass @@ -1751,7 +1764,17 @@ def i_load_the_ifc_test_file(filepath): @given("I load the demo construction library") @when("I load the demo construction library") def i_add_a_construction_library(): - lib_path = "./bonsai/bim/data/libraries/IFC4 Demo Library.ifc" + # Pick the library file whose schema matches the current project so the + # appended types are valid (IFC2X3-vs-IFC4 entity attributes differ). + schema_to_library = { + "IFC2X3": "IFC2X3 Demo Library.ifc", + "IFC4": "IFC4 Demo Library.ifc", + "IFC4X3": "IFC4X3 Demo Library.ifc", + "IFC4X3_ADD2": "IFC4X3 Demo Library.ifc", + } + schema = tool.Ifc.get().schema + lib_name = schema_to_library.get(schema, "IFC4 Demo Library.ifc") + lib_path = f"./bonsai/bim/data/libraries/{lib_name}" bpy.ops.bim.select_library_file(filepath=lib_path, append_all=True) diff --git a/src/bonsai/test/bim/test_handler_forward_compat.py b/src/bonsai/test/bim/test_handler_forward_compat.py new file mode 100644 index 0000000000..a840c3ba6b --- /dev/null +++ b/src/bonsai/test/bim/test_handler_forward_compat.py @@ -0,0 +1,177 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Forward-compat AST contracts for the BIM Tool refresh path. + +Pins structural invariants that no behavioural test can catch on its own: +the commit-driven header refresh fires only for the parametric validate- +gizmo operators (``bim.finish_editing_``), never universally — and +the header writer never drifts into user-intent enum writes.""" + +import ast +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.model + + +HANDLER_PATH = Path(__file__).parent.parent.parent / "bonsai" / "bim" / "handler.py" +PARAMETRIC_PATH = HANDLER_PATH.parent.parent / "tool" / "parametric.py" +MODEL_MODULE_DIR = HANDLER_PATH.parent / "module" / "model" + +# User-intent enums encode the user's "what to build next" choice on the +# BIM Tool panel. The header-only writer must never drift into enum writes; +# user-intent enums are owned by the selection-change path. +USER_INTENT_ENUM_ATTRS = frozenset({"ifc_class", "relating_type_id"}) + + +def _function_node(tree: ast.Module, name: str) -> ast.FunctionDef: + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == name: + return node + raise AssertionError(f"{name!r} not found in {HANDLER_PATH.name}") + + +@pytest.fixture(scope="module") +def handler_tree() -> ast.Module: + return ast.parse(HANDLER_PATH.read_text(encoding="utf-8")) + + +def test_read_headers_into_props_writes_only_header_floats(handler_tree: ast.Module) -> None: + """``_read_headers_into_props`` is the header-only writer called from + the selection-driven refresh. It must not assign to user-intent enum + slots (``ifc_class``, ``relating_type_id``); those are the + 'what to build next' choice and have their own targeted writes + earlier in ``update_bim_tool_props``.""" + fn = _function_node(handler_tree, "_read_headers_into_props") + offenders = [] + for node in ast.walk(fn): + if not isinstance(node, ast.Assign): + continue + for target in node.targets: + if isinstance(target, ast.Attribute) and target.attr in USER_INTENT_ENUM_ATTRS: + offenders.append((target.attr, node.lineno)) + if offenders: + msgs = ", ".join(f"{attr} at line {line}" for attr, line in offenders) + pytest.fail( + f"_read_headers_into_props assigns to user-intent enum slot(s): {msgs}. " + f"Header refresh must not re-target the user's BIM Tool panel selection." + ) + + +def test_refresh_post_commit_gates_header_refresh_on_edit_types_registry() -> None: + """``tool.Parametric.refresh_post_commit`` fires for every IFC + operator commit. Only operators whose ``bl_idname`` matches a + ``ParametricObject.finish_op`` in ``EDIT_TYPES`` (the validate- + gizmo path) must trigger a BIM Tool header refresh — selection + didn't change but the header values did. Other operators must + skip the refresh: they don't target an active-object header edit, + and their commit context may lack the view-layer attributes the + refresh reads. + + The gate must consult the registry, not match a string prefix — + ``EDIT_TYPES`` is the canonical list of parametric features, and + querying it stays correct even if ``ParametricObject.finish_op`` + changes its derivation rule.""" + parametric_tree = ast.parse(PARAMETRIC_PATH.read_text(encoding="utf-8")) + fn = _function_node(parametric_tree, "refresh_post_commit") + found_gated_call = False + for node in ast.walk(fn): + if not isinstance(node, ast.If): + continue + references_registry = any( + isinstance(sub, ast.Attribute) and sub.attr == "EDIT_TYPES" for sub in ast.walk(node.test) + ) + if not references_registry: + continue + for body_node in ast.walk(node): + if ( + isinstance(body_node, ast.Call) + and isinstance(body_node.func, ast.Attribute) + and body_node.func.attr == "refresh_bim_tool_headers" + ): + found_gated_call = True + break + if found_gated_call: + break + assert found_gated_call, ( + "tool.Parametric.refresh_post_commit must gate refresh_bim_tool_headers on an " + "If whose test references EDIT_TYPES (the parametric registry). An ungated call " + "fires the refresh for commits in contexts that strip view-layer attributes; " + "a missing call silently drops the validate-gizmo header refresh." + ) + + +def _modules_with_module_scope_cache_and_clear(): + """Yield ``module_name`` for every ``bim/module/model/*.py`` source that + declares a module-scope ``GenerationKeyedCache()`` assignment AND a + top-level ``def clear_caches``. These are the modules whose cache state + survives file loads and must be drained from ``_apply_save_file_invariants``.""" + for path in MODEL_MODULE_DIR.glob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8")) + has_cache = False + has_clear = False + for node in tree.body: + if isinstance(node, ast.FunctionDef) and node.name == "clear_caches": + has_clear = True + continue + if isinstance(node, ast.Assign): + for sub in ast.walk(node.value): + if ( + isinstance(sub, ast.Call) + and isinstance(sub.func, ast.Attribute) + and sub.func.attr == "GenerationKeyedCache" + ): + has_cache = True + break + if has_cache and has_clear: + yield path.stem + + +def test_on_load_post_drains_every_module_scope_geom_cache() -> None: + """Module-scope ``GenerationKeyedCache`` instances persist across file + loads — the counter they invalidate against is class-level and survives + a ``.blend`` reload. Without a ``load_post`` drain the cache may serve + entries whose ``bpy_struct`` references point into the previous file's + freed ``bpy.data``, raising ``ReferenceError`` on the next attribute read. + + Pin: every model module that exposes both a module-scope cache and a + top-level ``clear_caches`` is called from ``tool.Parametric.on_load_post``, + the central post-load drain.""" + parametric_tree = ast.parse(PARAMETRIC_PATH.read_text(encoding="utf-8")) + fn = _function_node(parametric_tree, "on_load_post") + drained: set[str] = set() + for node in ast.walk(fn): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "clear_caches" + and isinstance(node.func.value, ast.Name) + ): + drained.add(node.func.value.id) + missing = [name for name in _modules_with_module_scope_cache_and_clear() if name not in drained] + if missing: + pytest.fail( + "Module(s) expose a module-scope GenerationKeyedCache + clear_caches() but " + f"tool.Parametric.on_load_post does not drain them on load_post: {sorted(missing)}. " + "Add a `.clear_caches()` call so freshly-loaded files cannot serve " + "entries holding freed bpy.data references from the previous file." + ) diff --git a/src/bonsai/test/bim/test_handler_restricted_context.py b/src/bonsai/test/bim/test_handler_restricted_context.py new file mode 100644 index 0000000000..dac9293275 --- /dev/null +++ b/src/bonsai/test/bim/test_handler_restricted_context.py @@ -0,0 +1,74 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Restricted-context regression test for ``tool.Blender.get_active_object``. + +Some Blender contexts (e.g. the C-side operator context handed to +programmatically-invoked nested ``bpy.ops`` calls) lack the view-layer +attributes a normal UI context exposes. The canonical accessor must +return ``None`` in that case rather than ``AttributeError`` — otherwise +every caller routed through it inherits the same crash class that +originally broke ``bpy.ops.bim.new_project(preset='demo')``.""" + +from unittest.mock import patch + +import pytest + +pytestmark = pytest.mark.model + + +class _RestrictedContext: + """Stand-in for a ``bpy.context`` stripped of view-layer attributes.""" + + def __getattr__(self, name): + raise AttributeError(name) + + +def test_get_active_object_returns_none_in_restricted_context(): + """``tool.Blender.get_active_object`` is the canonical defensive + accessor. Both the primary read (``bpy.context.active_object``) and + the fallback (``bpy.context.view_layer.objects.active``) must + tolerate a stripped context — otherwise the 150+ callers in the + codebase that route through this helper inherit the crash.""" + import bonsai.tool.blender as blender_tool + + with patch.object(blender_tool, "bpy") as bpy_patch: + bpy_patch.context = _RestrictedContext() + assert blender_tool.Blender.get_active_object() is None + + +def test_property_header_tools_whitelists_bim_tool_family_only(): + """``tool.Blender.get_property_header_tools`` gates the validate- + gizmo header refresh. Parametric ``BimTool`` subclasses and the + base ``BimTool`` itself must be included; ``AnnotationTool`` and + workspace tools outside the ``BimTool`` family (spatial, structural, + etc.) must not — they don't surface these header floats.""" + import bonsai.tool as tool_ + + # Ensure the lru_cache picks up subclasses registered by the + # current Blender session (idempotent if already populated). + tool_.Blender.get_property_header_tools.cache_clear() + headers = tool_.Blender.get_property_header_tools() + + assert "bim.bim_tool" in headers, "base BimTool must surface property headers" + assert "bim.wall_tool" in headers, "parametric BimTool subclass must surface property headers" + assert "bim.annotation_tool" not in headers, "AnnotationTool is not a BimTool subclass — no header surface" + assert "bim.spatial_tool" not in headers, "SpatialTool is not BimTool-derived" + assert "bim.structural_tool" not in headers, "StructuralTool is not BimTool-derived" diff --git a/src/bonsai/test/bim/test_parametric_lifecycle.py b/src/bonsai/test/bim/test_parametric_lifecycle.py new file mode 100644 index 0000000000..97bd53ff40 --- /dev/null +++ b/src/bonsai/test/bim/test_parametric_lifecycle.py @@ -0,0 +1,411 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Unit coverage for the shared parametric-edit lifecycle mixins. + +``bonsai.bim.parametric_lifecycle`` is the load-bearing path for 4 of 6 +parametric features (door, window, railing, roof). The registry smoke test +elsewhere verifies operators are wired up; the mixins' own state-transition +contracts are tested here. + +The mixins are exercised through minimal in-test subclasses that supply the +abstract hooks (``_is_element_type``, ``_get_props``, etc.). All ``tool.*`` and +``ifcopenshell.*`` references at the module top of ``parametric_lifecycle`` are +patched at the module attribute (not the source module) so each test sees +isolated mock state.""" + +import json +from typing import ClassVar +from unittest import mock + +import pytest + +pytestmark = pytest.mark.model + + +@pytest.fixture(autouse=True) +def _require_real_bpy(): + import types as _types + + import bpy + + if not isinstance(bpy, _types.ModuleType) or hasattr(bpy, "_mock_name"): + pytest.skip("requires real Blender (bpy is mocked or absent)") + + +class _FakeProps: + """Stand-in for ``BIMProperties`` — records what was set so tests can + assert state transitions without instantiating real PropertyGroups.""" + + def __init__(self): + self.is_editing = False + self.last_kwargs = None + self.general = {"width": 1000} + self.lining = {"thickness": 50} + self.panel = {"material": "wood"} + + def set_props_kwargs_from_ifc_data(self, data): + self.last_kwargs = dict(data) + + def get_general_kwargs(self, convert_to_project_units=True): + return dict(self.general) + + def get_lining_kwargs(self, convert_to_project_units=True): + return dict(self.lining) + + def get_panel_kwargs(self, convert_to_project_units=True): + return dict(self.panel) + + +def _make_obj(props): + obj = mock.Mock() + obj.props = props + obj.name = "TestObj" + return obj + + +def _make_pset_text(general, lining, panel): + payload = {"lining_properties": lining, "panel_properties": panel, **general} + return json.dumps(payload) + + +# ---------------------------------------------------------------------- +# FeatureModifierEditMixin (door/window pattern) +# ---------------------------------------------------------------------- + + +def _door_mixin_cls(match=True, raise_on_update=False): + from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin + + raised = raise_on_update + + class _TestDoorMixin(FeatureModifierEditMixin): + pset_name: ClassVar[str] = "BBIM_Door" + representations_called: ClassVar[list] = [] + + @classmethod + def _is_element_type(cls, element): + return match + + @classmethod + def _get_props(cls, obj): + return obj.props + + @classmethod + def _update_modifier_representation(cls, obj, context): + cls.representations_called.append(obj) + if raised: + raise RuntimeError("simulated representation failure") + + return _TestDoorMixin + + +@pytest.fixture +def patched_tool_and_ifc(): + """Patch ``tool`` and ``ifcopenshell.*`` references on the lifecycle module. + + Yields ``(mock_tool, mock_ifc_util_element, mock_ifc_api_pset, + mock_ifc_util_rep, mock_core_geometry)`` so tests can configure return + values and assert call args.""" + target = "bonsai.bim.parametric_lifecycle" + with mock.patch(f"{target}.tool") as mock_tool, mock.patch(f"{target}.ifcopenshell") as mock_ifc, mock.patch( + f"{target}.bonsai" + ) as mock_bonsai: + # Element returned by tool.Ifc.get_entity is reused across mocks. + element = mock.Mock(name="entity") + mock_tool.Ifc.get_entity.return_value = element + mock_tool.Ifc.get.return_value = mock.Mock(name="ifc_file") + mock_tool.Model.get_constituents_props_data.return_value = {"materials": []} + mock_tool.Pset.get_element_pset.return_value = mock.Mock(name="pset") + mock_ifc.util.element.get_type.return_value = None # skip thumbnail mark + yield { + "tool": mock_tool, + "ifc": mock_ifc, + "bonsai": mock_bonsai, + "element": element, + } + + +def test_feature_modifier_enable_one_sets_is_editing_and_loads_kwargs(patched_tool_and_ifc): + props = _FakeProps() + obj = _make_obj(props) + patched_tool_and_ifc["ifc"].util.element.get_pset.return_value = _make_pset_text( + {"width": 1234}, {"thickness": 50}, {"material": "wood"} + ) + + cls = _door_mixin_cls(match=True) + cls._enable_one(obj) + + assert props.is_editing is True + assert props.last_kwargs is not None + assert props.last_kwargs["width"] == 1234 + assert props.last_kwargs["thickness"] == 50 + assert props.last_kwargs["material"] == "wood" + assert "materials" in props.last_kwargs # from get_constituents_props_data + + +def test_feature_modifier_enable_one_noop_when_element_not_match(patched_tool_and_ifc): + props = _FakeProps() + obj = _make_obj(props) + + cls = _door_mixin_cls(match=False) + cls._enable_one(obj) + + assert props.is_editing is False + assert props.last_kwargs is None + # get_pset must not be called when _is_element_type returns False — the + # _resolve guard short-circuits before reading pset data. + patched_tool_and_ifc["ifc"].util.element.get_pset.assert_not_called() + + +def test_feature_modifier_enable_one_noop_when_no_entity(patched_tool_and_ifc): + """tool.Ifc.get_entity returning None must short-circuit before predicate runs.""" + props = _FakeProps() + obj = _make_obj(props) + patched_tool_and_ifc["tool"].Ifc.get_entity.return_value = None + + cls = _door_mixin_cls(match=True) + cls._enable_one(obj) + + assert props.is_editing is False + + +def test_feature_modifier_finish_one_clears_is_editing_and_writes_pset(patched_tool_and_ifc): + props = _FakeProps() + props.is_editing = True + obj = _make_obj(props) + ctx = mock.Mock(name="context") + + cls = _door_mixin_cls(match=True) + cls._finish_one(obj, ctx) + + assert props.is_editing is False + assert obj in cls.representations_called + # tool.Pset.write_bbim_data is called exactly once with the merged dict. + patched_tool_and_ifc["tool"].Pset.write_bbim_data.assert_called_once() + call_args = patched_tool_and_ifc["tool"].Pset.write_bbim_data.call_args + assert call_args.args[1] == "BBIM_Door" # pset_name positional arg + written_data = call_args.args[2] + assert "lining_properties" in written_data and "panel_properties" in written_data + + +def test_feature_modifier_finish_one_exception_leaves_draft_in_progress(patched_tool_and_ifc): + """If _update_modifier_representation raises, is_editing must stay True + so the user's draft survives for retry. This is the contract called out + in parametric_lifecycle.py:161 — set is_editing=False only on success.""" + props = _FakeProps() + props.is_editing = True + obj = _make_obj(props) + ctx = mock.Mock(name="context") + + cls = _door_mixin_cls(match=True, raise_on_update=True) + with pytest.raises(RuntimeError, match="simulated representation failure"): + cls._finish_one(obj, ctx) + + assert props.is_editing is True # draft survives + + +def test_feature_modifier_cancel_one_restores_and_clears_is_editing(patched_tool_and_ifc): + props = _FakeProps() + props.is_editing = True + obj = _make_obj(props) + patched_tool_and_ifc["ifc"].util.element.get_pset.return_value = _make_pset_text( + {"width": 900}, {"thickness": 60}, {"material": "steel"} + ) + + cls = _door_mixin_cls(match=True) + cls._cancel_one(obj) + + assert props.is_editing is False + assert props.last_kwargs is not None and props.last_kwargs["width"] == 900 + # switch_representation must be called via bonsai.core.geometry. + patched_tool_and_ifc["bonsai"].core.geometry.switch_representation.assert_called_once() + + +def test_feature_modifier_targets_loop_uses_iter_targets(patched_tool_and_ifc): + """_enable_targets / _finish_targets / _cancel_targets iterate + _iter_targets — default is [active_object]; subclasses can override.""" + props_a, props_b = _FakeProps(), _FakeProps() + obj_a, obj_b = _make_obj(props_a), _make_obj(props_b) + patched_tool_and_ifc["ifc"].util.element.get_pset.return_value = _make_pset_text( + {"width": 1000}, {"thickness": 50}, {"material": "wood"} + ) + + cls = _door_mixin_cls(match=True) + cls._iter_targets = classmethod(lambda c, ctx: [obj_a, obj_b]) + + result = cls()._enable_targets(mock.Mock()) + + assert result == {"FINISHED"} + assert props_a.is_editing is True + assert props_b.is_editing is True + + +# ---------------------------------------------------------------------- +# PathPreservingEditMixin (railing/roof pattern) +# ---------------------------------------------------------------------- + + +class _FakePathProps: + """Stand-in for railing/roof properties — get_general_kwargs only (no lining/panel).""" + + def __init__(self): + self.is_editing = False + self.last_kwargs = None + self.general = {"width": 200, "thickness": 10} + + def set_props_kwargs_from_ifc_data(self, data): + self.last_kwargs = dict(data) + + def get_general_kwargs(self, convert_to_project_units=True): + return dict(self.general) + + +def _path_mixin_cls(match=True): + from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin + + class _TestPathMixin(PathPreservingEditMixin): + pset_name: ClassVar[str] = "BBIM_Railing" + pset_updates: ClassVar[list] = [] + ifc_data_updates: ClassVar[list] = [] + bmesh_updates: ClassVar[list] = [] + + @classmethod + def _is_element_type(cls, element): + return match + + @classmethod + def _get_props(cls, obj): + return obj.props + + @classmethod + def _update_pset(cls, element, data): + cls.pset_updates.append((element, data)) + + @classmethod + def _update_modifier_ifc_data(cls, obj, context): + cls.ifc_data_updates.append(obj) + + @classmethod + def _restore_viewport_after_cancel(cls, obj, context): + cls.bmesh_updates.append(obj) + + return _TestPathMixin + + +def test_path_preserving_enable_one_sets_is_editing(patched_tool_and_ifc): + props = _FakePathProps() + obj = _make_obj(props) + patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = { + "data_dict": {"width": 250, "path_data": {"points": [[0, 0], [1, 0]]}} + } + + cls = _path_mixin_cls(match=True) + cls._enable_one(obj) + + assert props.is_editing is True + assert props.last_kwargs is not None + assert props.last_kwargs["width"] == 250 + # path_data passes through (default _post_load_data is pass-through) + assert props.last_kwargs["path_data"] == {"points": [[0, 0], [1, 0]]} + + +def test_path_preserving_finish_one_preserves_path_data_and_clears_is_editing(patched_tool_and_ifc): + props = _FakePathProps() + props.is_editing = True + obj = _make_obj(props) + ctx = mock.Mock(name="context") + sentinel_path = {"points": [[5, 5], [9, 9]], "edges": [[0, 1]]} + patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = { + "data_dict": {"path_data": sentinel_path} + } + + cls = _path_mixin_cls(match=True) + cls._finish_one(obj, ctx) + + assert props.is_editing is False + assert cls.pset_updates, "_update_pset must be called on Finish" + assert cls.pset_updates[-1][1]["path_data"] is sentinel_path # preserved by reference + assert obj in cls.ifc_data_updates + + +def test_path_preserving_cancel_one_calls_restore_viewport_after_cancel(patched_tool_and_ifc): + props = _FakePathProps() + props.is_editing = True + obj = _make_obj(props) + ctx = mock.Mock(name="context") + patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = { + "data_dict": {"width": 250, "path_data": {"points": []}} + } + + cls = _path_mixin_cls(match=True) + cls._cancel_one(obj, ctx) + + assert props.is_editing is False + assert obj in cls.bmesh_updates + + +def test_path_preserving_enable_one_post_load_data_hook_runs(patched_tool_and_ifc): + """Railing overrides _post_load_data to JSON-serialise path_data — + confirm the hook is honoured (here we drop a sentinel key).""" + props = _FakePathProps() + obj = _make_obj(props) + patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = { + "data_dict": {"width": 250, "extra": "drop_me"} + } + + cls = _path_mixin_cls(match=True) + cls._post_load_data = classmethod(lambda c, data: {k: v for k, v in data.items() if k != "extra"}) + cls._enable_one(obj) + + assert "extra" not in props.last_kwargs + + +# ---------------------------------------------------------------------- +# _ParametricEditMixinBase._resolve guard +# ---------------------------------------------------------------------- + + +def test_resolve_returns_none_when_obj_has_no_entity(patched_tool_and_ifc): + cls = _door_mixin_cls(match=True) + patched_tool_and_ifc["tool"].Ifc.get_entity.return_value = None + obj = _make_obj(_FakeProps()) + + assert cls._resolve(obj) is None + + +def test_resolve_returns_none_when_element_type_mismatch(patched_tool_and_ifc): + cls = _door_mixin_cls(match=False) + obj = _make_obj(_FakeProps()) + + assert cls._resolve(obj) is None + + +def test_resolve_returns_tuple_when_match(patched_tool_and_ifc): + cls = _door_mixin_cls(match=True) + props = _FakeProps() + obj = _make_obj(props) + + resolved = cls._resolve(obj) + + assert resolved is not None + element, returned_props = resolved + assert element is patched_tool_and_ifc["element"] + assert returned_props is props diff --git a/src/bonsai/test/bim/test_parametric_registry.py b/src/bonsai/test/bim/test_parametric_registry.py new file mode 100644 index 0000000000..1c1e3ec3f7 --- /dev/null +++ b/src/bonsai/test/bim/test_parametric_registry.py @@ -0,0 +1,146 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Registration smoke test for `tool.Parametric.EDIT_TYPES`. + +The registry is the single source of truth for which parametric element types +exist. Every consumer (auto-commit on save, finish/cancel chains, the +``PointerProperty`` attachment, the ``GizmoPreferences`` per-feature toggle) +derives identifiers from each entry's short ``name`` token. Forget any +downstream registration and the silent-desync the framework exists to prevent +will ship. + +These tests pin the registry-to-runtime contract: for every entry the operator +``bl_idname``s resolve to registered ``bpy.ops.bim.*`` callables, the +``PropertyGroup`` class is attached to ``bpy.types.Object``, and the per-type +predicate exists on `tool.Parametric`.""" + +import types + +import bpy +import pytest + +pytestmark = pytest.mark.model + + +@pytest.fixture(autouse=True) +def _require_real_bpy(): + if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"): + pytest.skip("requires real Blender (bpy is mocked or absent)") + + +@pytest.fixture +def registry(): + from bonsai import tool + + return tool.Parametric.EDIT_TYPES + + +def test_registry_is_non_empty(registry): + assert len(registry) >= 1 + + +def test_every_entry_has_enable_op_registered(registry): + missing = [e.enable_op for e in registry if not hasattr(bpy.ops.bim, e.enable_op.removeprefix("bim."))] + assert not missing, f"Missing enable operators: {missing}" + + +def test_every_entry_has_finish_op_registered(registry): + missing = [e.finish_op for e in registry if not hasattr(bpy.ops.bim, e.finish_op.removeprefix("bim."))] + assert not missing, f"Missing finish operators: {missing}" + + +def test_every_entry_has_cancel_op_registered(registry): + missing = [e.cancel_op for e in registry if not hasattr(bpy.ops.bim, e.cancel_op.removeprefix("bim."))] + assert not missing, f"Missing cancel operators: {missing}" + + +def test_every_entry_has_property_group_attached(registry): + # ``register_object_properties`` runs at addon enable; if any entry's + # PropertyGroup class is missing on prop module the attribute is skipped. + missing = [e.props_attr for e in registry if not hasattr(bpy.types.Object, e.props_attr)] + assert not missing, ( + f"bpy.types.Object missing attributes: {missing} — " + f"verify the matching PropertyGroup classes exist in bim.module.model.prop" + ) + + +def test_every_entry_has_parametric_predicate(registry): + from bonsai import tool + + missing = [e.name for e in registry if getattr(tool.Parametric, f"is_{e.name}", None) is None] + assert not missing, f"tool.Parametric missing is_ predicates: {missing}" + + +def test_every_predicate_does_not_raise_on_non_matching_element(registry): + """Each ``is_`` predicate must be **total**: accept any IFC entity + and return a truthy/falsy value, never raise. + + The registry iterates every predicate against the active IFC element on + save; a raising predicate (e.g. ``AttributeError`` from a missing pset + accessor when handed a non-matching element type) propagates upward and + breaks the save path for *all* parametric types, not just its own. + This test probes each predicate with an ``IfcAnnotation`` (an element + that carries none of the BBIM_ psets the predicates look up) and + asserts the call does not raise. Falsy returns are acceptable — the + registry treats them as 'no match'. What's forbidden is raising.""" + import ifcopenshell + + from bonsai import tool + + probe = ifcopenshell.file(schema="IFC4").create_entity("IfcAnnotation") + + raised = [] + for feature in registry: + predicate = getattr(tool.Parametric, f"is_{feature.name}", None) + if predicate is None: + continue + try: + predicate(probe) + except Exception as e: + raised.append((feature.name, type(e).__name__, str(e))) + assert not raised, ( + f"is_ predicates raised on a non-matching IfcAnnotation: {raised}. " + f"Predicates must be total — return bool, never raise. Add an " + f"`if not element.is_a('IfcXxx'): return False` short-circuit or guard the pset lookup." + ) + + +def test_gizmo_preferences_field_per_registry_entry(registry): + """Every registry entry must have a matching ``: BoolProperty`` field + on ``ui.GizmoPreferences`` so the addon-preferences UI auto-renders a + toggle for it and ``BaseParametricGizmoGroup.poll`` can gate the whole + gizmo group on ``prefs.gizmos.``. + + Checks ``__annotations__`` rather than ``hasattr`` because Blender's + PropertyGroup syntax (``field: bpy.props.BoolProperty(...)``) is an + annotation-only assignment — the attribute only materialises on the + class after Blender's metaclass installs the bpy_struct descriptor, + which depends on registration timing. Reading ``__annotations__`` + pins the source-level contract independently of when register() ran.""" + from bonsai.bim import ui + + annotations = getattr(ui.GizmoPreferences, "__annotations__", {}) + missing = [feature.name for feature in registry if feature.name not in annotations] + assert not missing, ( + f"ui.GizmoPreferences missing BoolProperty field(s) for: {missing} — " + f"each registry entry must have a matching ``: BoolProperty(...)`` " + f"field on ``ui.GizmoPreferences`` so the preferences UI surfaces a toggle" + ) diff --git a/src/bonsai/test/bim/test_pen_dispatcher_forward_compat.py b/src/bonsai/test/bim/test_pen_dispatcher_forward_compat.py new file mode 100644 index 0000000000..6ebf78f239 --- /dev/null +++ b/src/bonsai/test/bim/test_pen_dispatcher_forward_compat.py @@ -0,0 +1,91 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Forward-compat AST contract for the pen-icon dispatcher monopoly. + +Every parametric gizmo group's pen icon must bind to the universal +``bim.enable_editing_parametric`` dispatcher rather than the feature's own +enable operator. The dispatcher is the single chokepoint where pre-edit +checks (shared-representation warning, future safety gates) run; a feature +that binds directly bypasses every such check silently.""" + +import ast +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.drawing + + +BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai" +BIM_DIR = BONSAI_ROOT / "bim" +DISPATCHER_IDNAME = "bim.enable_editing_parametric" + + +def _iter_pen_gizmo_target_set_operator_calls(tree: ast.Module): + """Yield each ``ast.Call`` matching ``.pen_gizmo.target_set_operator(...)``. + Receiver is any attribute access (``self.pen_gizmo``, ``group.pen_gizmo``, etc.).""" + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if not isinstance(func, ast.Attribute) or func.attr != "target_set_operator": + continue + receiver = func.value + if not isinstance(receiver, ast.Attribute) or receiver.attr != "pen_gizmo": + continue + yield node + + +def test_every_pen_gizmo_binding_routes_through_the_universal_dispatcher() -> None: + violations: list[str] = [] + found_any = False + for path in BIM_DIR.rglob("*.py"): + try: + tree = ast.parse(path.read_text(encoding="utf-8")) + except SyntaxError: + continue + for call in _iter_pen_gizmo_target_set_operator_calls(tree): + found_any = True + if not call.args: + violations.append(f"{path}:{call.lineno} pen_gizmo.target_set_operator() called with no args") + continue + first_arg = call.args[0] + if not isinstance(first_arg, ast.Constant) or not isinstance(first_arg.value, str): + violations.append( + f"{path}:{call.lineno} pen_gizmo.target_set_operator() first arg is not a string literal" + ) + continue + if first_arg.value != DISPATCHER_IDNAME: + violations.append( + f"{path}:{call.lineno} pen_gizmo.target_set_operator({first_arg.value!r}) " + f"bypasses the universal dispatcher" + ) + + assert found_any, ( + "No pen_gizmo.target_set_operator(...) calls found anywhere under bim/. " + "Either the gizmo-binding pattern has been refactored away (this test " + "needs updating) or the search root is wrong." + ) + assert not violations, ( + "Pen-icon bindings must route through the universal dispatcher " + f"({DISPATCHER_IDNAME!r}) so the shared-representation warning and any " + "future pre-edit checks apply to every feature. Violations:\n " + "\n ".join(violations) + ) diff --git a/src/bonsai/test/bim/test_preview_cancel_ops_forward_compat.py b/src/bonsai/test/bim/test_preview_cancel_ops_forward_compat.py new file mode 100644 index 0000000000..d67f258eaa --- /dev/null +++ b/src/bonsai/test/bim/test_preview_cancel_ops_forward_compat.py @@ -0,0 +1,144 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Forward-compat AST contract for the preview cancellation registry. + +Every ``PointerProperty`` child of ``BIMPreviewProperties`` whose target +PropertyGroup declares an ``is_active`` BoolProperty is a Scene-level +preview. Each must have a matching ``(child_attr, cancel_op_name)`` entry +in ``preview_base.PREVIEW_CANCEL_OPS`` so the Esc dispatcher and the +``load_post`` stale-flag discard both cover it. + +A new preview type that defines its own Enable / Decorator without +registering the cancel pair will silently ignore Esc and leave a stuck +``is_active`` flag across file reloads — exactly the failure mode the +sibling forward-compat guards exist to prevent.""" + +import ast +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.model + + +BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai" +PROP_FILE = BONSAI_ROOT / "bim" / "module" / "model" / "prop.py" +UMBRELLA_CLASS = "BIMPreviewProperties" + + +def _find_class(tree: ast.Module, name: str) -> ast.ClassDef | None: + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef) and node.name == name: + return node + return None + + +def _iter_pointer_property_children(class_node: ast.ClassDef): + """Yield ``(attr_name, target_class_name)`` for each + ``: bpy.props.PointerProperty(type=)`` annotated + assignment in the umbrella class body. + + Bonsai follows the Blender convention where the property call lives in + the *annotation* (PEP 526 syntax) rather than the value — Blender's + PropertyGroup metaclass picks it up at class creation time.""" + for node in class_node.body: + if not isinstance(node, ast.AnnAssign) or not isinstance(node.target, ast.Name): + continue + if not isinstance(node.annotation, ast.Call): + continue + func = node.annotation.func + if not isinstance(func, ast.Attribute) or func.attr != "PointerProperty": + continue + for kw in node.annotation.keywords: + if kw.arg == "type" and isinstance(kw.value, ast.Name): + yield node.target.id, kw.value.id + break + + +def _class_has_is_active_bool(class_node: ast.ClassDef) -> bool: + """Return True if ``class_node`` declares ``is_active: bpy.props.BoolProperty(...)``.""" + for node in class_node.body: + if not isinstance(node, ast.AnnAssign) or not isinstance(node.target, ast.Name): + continue + if node.target.id != "is_active": + continue + if not isinstance(node.annotation, ast.Call): + continue + func = node.annotation.func + if isinstance(func, ast.Attribute) and func.attr == "BoolProperty": + return True + return False + + +def test_every_preview_propertygroup_is_registered_in_cancel_ops() -> None: + from bonsai.bim.module.model import preview_base + + registered_attrs = {attr for attr, _op in preview_base.PREVIEW_CANCEL_OPS} + + tree = ast.parse(PROP_FILE.read_text(encoding="utf-8")) + umbrella = _find_class(tree, UMBRELLA_CLASS) + assert umbrella is not None, ( + f"Could not find {UMBRELLA_CLASS!r} in {PROP_FILE}. Either the umbrella class " + "was renamed (this test needs updating) or prop.py was restructured." + ) + + preview_children: list[tuple[str, str]] = [] + for attr, target_class_name in _iter_pointer_property_children(umbrella): + target = _find_class(tree, target_class_name) + if target is None: + continue + if _class_has_is_active_bool(target): + preview_children.append((attr, target_class_name)) + + assert preview_children, ( + "No PointerProperty children with ``is_active`` BoolProperty found under " + f"{UMBRELLA_CLASS}. Either the preview convention has been refactored away " + "(this test needs updating) or prop.py was restructured." + ) + + missing = [(attr, cls) for attr, cls in preview_children if attr not in registered_attrs] + assert not missing, ( + "Every Scene-level preview PropertyGroup must have a matching " + "(child_attr, cancel_op_name) tuple in preview_base.PREVIEW_CANCEL_OPS so " + "Esc dispatch and load_post stale-flag discard cover it. Missing entries:\n " + + "\n ".join(f"BIMPreviewProperties.{attr} (target={cls!r})" for attr, cls in missing) + ) + + +def test_every_cancel_ops_entry_has_a_real_preview_propertygroup() -> None: + """The reverse contract: a stale entry in ``PREVIEW_CANCEL_OPS`` whose + PropertyGroup has been deleted would silently leak to every Esc press + (dispatching to a missing operator raises ``AttributeError`` inside + ``try_cancel_active_preview``). Pin that the registry never goes + stale relative to ``BIMPreviewProperties``.""" + from bonsai.bim.module.model import preview_base + + tree = ast.parse(PROP_FILE.read_text(encoding="utf-8")) + umbrella = _find_class(tree, UMBRELLA_CLASS) + assert umbrella is not None + + declared_attrs = {attr for attr, _target in _iter_pointer_property_children(umbrella)} + orphaned = [attr for attr, _op in preview_base.PREVIEW_CANCEL_OPS if attr not in declared_attrs] + assert not orphaned, ( + "PREVIEW_CANCEL_OPS contains entries whose PointerProperty child no longer " + f"exists on {UMBRELLA_CLASS}. Drop the stale tuple(s):\n " + + "\n ".join(orphaned) + ) diff --git a/src/bonsai/test/core/test_model.py b/src/bonsai/test/core/test_model.py new file mode 100644 index 0000000000..fe6e9903b6 --- /dev/null +++ b/src/bonsai/test/core/test_model.py @@ -0,0 +1,243 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Tests for pure-Python math helpers in bonsai.core.model used by the wall gizmo system. + +These run in the core lane (``pytest test/core/``) — no Blender, no IFC file. The +helpers under test live in ``bonsai/core/model.py`` and are deliberately pure (tuple +in, tuple out) so they're exercisable without ``mathutils`` or ``bpy``.""" + +import math + +import pytest + +import bonsai.core.model as subject + + +class TestBaselineFromOffset: + THICKNESS = 0.2 + + def test_positive_direction_exterior(self): + assert subject.baseline_from_offset(0.0, self.THICKNESS) == "EXTERIOR" + + def test_positive_direction_center(self): + assert subject.baseline_from_offset(-self.THICKNESS / 2, self.THICKNESS) == "CENTER" + + def test_positive_direction_interior(self): + assert subject.baseline_from_offset(-self.THICKNESS, self.THICKNESS) == "INTERIOR" + + def test_negative_direction_exterior(self): + assert subject.baseline_from_offset(self.THICKNESS, self.THICKNESS) == "EXTERIOR" + + def test_negative_direction_center(self): + assert subject.baseline_from_offset(self.THICKNESS / 2, self.THICKNESS) == "CENTER" + + def test_negative_direction_interior(self): + assert subject.baseline_from_offset(0.0, self.THICKNESS) == "EXTERIOR" + + def test_within_tolerance_still_matches(self): + # A 0.5mm jitter on a 200mm wall should still classify cleanly. + assert subject.baseline_from_offset(-self.THICKNESS / 2 + 0.0005, self.THICKNESS) == "CENTER" + + def test_outside_tolerance_falls_back_to_center(self): + # 50mm offset on a 200mm wall — not a canonical position. + assert subject.baseline_from_offset(0.05, self.THICKNESS) == "CENTER" + + +class TestProjectAxisIntersection: + PARALLEL_THRESHOLD = 0.9994 # cos(2°) + + def test_perpendicular_walls_meet_at_corner(self): + # Wall A along +X from origin; wall B along +Y from (5, 0, 0). + # Axes meet exactly at (5, 0). + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((5.0, 0.0, 0.0), (5.0, 3.0, 0.0)) + result = subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) + assert result is not None + assert result[0] == pytest.approx(5.0) + assert result[1] == pytest.approx(0.0) + + def test_offset_walls_intersect_at_extrapolated_point(self): + # Wall A: y=0 from x=1 to x=6. + # Wall B: x=0 from y=1 to y=4. + # Infinite-line intersection at (0, 0). + seg_a = ((1.0, 0.0, 0.0), (6.0, 0.0, 0.0)) + seg_b = ((0.0, 1.0, 0.0), (0.0, 4.0, 0.0)) + result = subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) + assert result is not None + assert result[0] == pytest.approx(0.0) + assert result[1] == pytest.approx(0.0) + + def test_parallel_walls_return_none(self): + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((0.0, 1.0, 0.0), (5.0, 1.0, 0.0)) + assert subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) is None + + def test_anti_parallel_walls_return_none(self): + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((5.0, 1.0, 0.0), (0.0, 1.0, 0.0)) # opposite direction + assert subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) is None + + def test_nearly_parallel_walls_return_none(self): + # 1° off parallel — within the ~2° dead-band. + angle = math.radians(1) + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((0.0, 1.0, 0.0), (5.0 * math.cos(angle), 1.0 + 5.0 * math.sin(angle), 0.0)) + assert subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) is None + + def test_zero_length_segment_returns_none(self): + seg_a = ((0.0, 0.0, 0.0), (0.0, 0.0, 0.0)) + seg_b = ((0.0, 0.0, 0.0), (1.0, 1.0, 0.0)) + assert subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) is None + + def test_intersection_z_is_average_of_endpoint_zs(self): + # Walls at different elevations; the icon-placement Z should be the average. + seg_a = ((0.0, 0.0, 1.0), (5.0, 0.0, 1.0)) # at z=1 + seg_b = ((5.0, 0.0, 3.0), (5.0, 3.0, 3.0)) # at z=3 + result = subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) + assert result is not None + assert result[2] == pytest.approx(2.0) + + +class TestSlopeRoundTrip: + def test_zero_angle_zero_displacement(self): + assert subject.displacement_from_x_angle(3.0, 0.0) == pytest.approx(0.0) + assert subject.x_angle_from_displacement(3.0, 0.0) == pytest.approx(0.0) + + def test_positive_angle_positive_displacement(self): + # 30° slope on a 3m wall → top moves ~1.732m in +Y. + displacement = subject.displacement_from_x_angle(3.0, math.radians(30)) + assert displacement == pytest.approx(3.0 * math.tan(math.radians(30))) + + def test_negative_angle_negative_displacement(self): + displacement = subject.displacement_from_x_angle(3.0, math.radians(-15)) + assert displacement < 0 + + def test_round_trip_preserves_angle(self): + # Drag-to-angle-to-drag preserves the original. + original_angle = math.radians(20) + displacement = subject.displacement_from_x_angle(3.0, original_angle) + recovered = subject.x_angle_from_displacement(3.0, displacement) + assert recovered == pytest.approx(original_angle, abs=1e-9) + + def test_round_trip_handles_zero_height(self): + # Walls of effectively zero height should not divide-by-zero. + recovered = subject.x_angle_from_displacement(0.0, 1.0) + assert recovered == pytest.approx(math.pi / 2, abs=1e-3) + + +class TestAreAxesCollinear: + PARALLEL_THRESHOLD = 0.9994 + LINE_TOLERANCE = 0.05 + + def test_end_to_end_walls_along_x_are_collinear(self): + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((5.0, 0.0, 0.0), (10.0, 0.0, 0.0)) + assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE) + + def test_separated_collinear_walls_with_gap(self): + # Walls with a 1m gap between them — still on the same line. + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((6.0, 0.0, 0.0), (10.0, 0.0, 0.0)) + assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE) + + def test_perpendicular_walls_are_not_collinear(self): + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((0.0, 0.0, 0.0), (0.0, 5.0, 0.0)) + assert not subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE) + + def test_parallel_walls_offset_perpendicular_are_not_collinear(self): + # Two parallel walls 1m apart — same direction but not the same line. + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((0.0, 1.0, 0.0), (5.0, 1.0, 0.0)) + assert not subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE) + + def test_anti_parallel_collinear_walls(self): + # Reversed direction on the same line still counts as collinear. + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((10.0, 0.0, 0.0), (6.0, 0.0, 0.0)) + assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE) + + def test_z_is_ignored_for_plan_collinearity(self): + # Walls on different floors are still considered collinear in plan. + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((5.0, 0.0, 3.0), (10.0, 0.0, 3.0)) + assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE) + + def test_zero_length_segment_is_not_collinear(self): + seg_a = ((0.0, 0.0, 0.0), (0.0, 0.0, 0.0)) + seg_b = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + assert not subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE) + + def test_slightly_off_line_within_tolerance(self): + # 2cm perpendicular offset — still within the 5cm tolerance. + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((5.0, 0.02, 0.0), (10.0, 0.02, 0.0)) + assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE) + + def test_too_far_off_line_fails_tolerance(self): + # 10cm perpendicular offset — outside the 5cm tolerance. + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((5.0, 0.10, 0.0), (10.0, 0.10, 0.0)) + assert not subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE) + + +class TestClosestEndpointMidpoint: + def test_end_to_end_walls_midpoint_is_the_shared_corner(self): + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((5.0, 0.0, 0.0), (10.0, 0.0, 0.0)) + result = subject.closest_endpoint_midpoint(seg_a, seg_b) + assert result == (pytest.approx(5.0), pytest.approx(0.0), pytest.approx(0.0)) + + def test_walls_with_gap_midpoint_is_in_the_gap(self): + # Wall A ends at x=5; wall B starts at x=7. Boundary midpoint is at x=6. + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((7.0, 0.0, 0.0), (12.0, 0.0, 0.0)) + result = subject.closest_endpoint_midpoint(seg_a, seg_b) + assert result == (pytest.approx(6.0), pytest.approx(0.0), pytest.approx(0.0)) + + def test_perpendicular_walls_midpoint_is_between_nearest_endpoints(self): + # Wall A's +X endpoint (5,0,0) and wall B's origin (5,0,0) → midpoint at (5,0,0). + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((5.0, 0.0, 0.0), (5.0, 3.0, 0.0)) + result = subject.closest_endpoint_midpoint(seg_a, seg_b) + assert result == (pytest.approx(5.0), pytest.approx(0.0), pytest.approx(0.0)) + + def test_z_averaged_when_walls_at_different_elevations(self): + seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)) + seg_b = ((5.0, 0.0, 3.0), (10.0, 0.0, 3.0)) + result = subject.closest_endpoint_midpoint(seg_a, seg_b) + # Closest pair: (5,0,0) and (5,0,3); midpoint Z = 1.5. + assert result[2] == pytest.approx(1.5) + + +class TestVerticalHeightFromExtrusionDepth: + def test_vertical_wall_returns_depth_unchanged(self): + assert subject.vertical_height_from_extrusion_depth(3.0, 0.0) == pytest.approx(3.0) + + def test_30_degree_slope(self): + # cos(30°) ≈ 0.866 → vertical height of a 3m slanted extrusion ≈ 2.598m. + result = subject.vertical_height_from_extrusion_depth(3.0, math.radians(30)) + assert result == pytest.approx(3.0 * math.cos(math.radians(30))) + + def test_negative_angle_yields_same_magnitude(self): + positive = subject.vertical_height_from_extrusion_depth(3.0, math.radians(30)) + negative = subject.vertical_height_from_extrusion_depth(3.0, math.radians(-30)) + assert positive == pytest.approx(negative) diff --git a/src/bonsai/test/core/test_root.py b/src/bonsai/test/core/test_root.py index 121236803d..96bd389d1d 100644 --- a/src/bonsai/test/core/test_root.py +++ b/src/bonsai/test/core/test_root.py @@ -56,6 +56,7 @@ class TestCopyClass: ifc.get_entity("data").should_be_called().will_return("new_representation") geometry.get_representation_name("new_representation").should_be_called().will_return("name") geometry.rename_object("data", "name").should_be_called() + root.has_material_styles("element").should_be_called().will_return(False) root.assign_body_styles("element", "obj").should_be_called() collector.assign("obj").should_be_called() subject.copy_class(ifc, collector, geometry, root, obj="obj") diff --git a/src/bonsai/test/core/test_spatial.py b/src/bonsai/test/core/test_spatial.py index ddc6116fdf..e5a03d2048 100644 --- a/src/bonsai/test/core/test_spatial.py +++ b/src/bonsai/test/core/test_spatial.py @@ -52,6 +52,35 @@ class TestAssignContainer: collector.assign("obj2").should_be_called() subject.assign_container(ifc, collector, spatial, container="container", objs=["obj"]) + def test_root_resolves_to_self_for_a_filling(self, ifc, collector, spatial): + ifc.get_entity("door_obj").should_be_called().will_return("door") + spatial.get_root_element("door").should_be_called().will_return("door") + spatial.disable_editing("door_obj").should_be_called() + spatial.get_decomposition("door").should_be_called().will_return(["door"]) + spatial.can_contain("container", "door").should_be_called().will_return(True) + ifc.run("spatial.assign_container", products=["door"], relating_structure="container").should_be_called() + ifc.get_object("door").should_be_called().will_return("door_obj") + collector.assign("door_obj").should_be_called() + subject.assign_container(ifc, collector, spatial, container="container", objs=["door_obj"]) + + def test_can_contain_is_evaluated_per_root_element(self, ifc, collector, spatial): + ifc.get_entity("door_obj").should_be_called().will_return("door") + spatial.get_root_element("door").should_be_called().will_return("door") + spatial.disable_editing("door_obj").should_be_called() + spatial.get_decomposition("door").should_be_called().will_return(["door"]) + ifc.get_entity("opening_obj").should_be_called().will_return("opening") + spatial.get_root_element("opening").should_be_called().will_return("opening") + spatial.disable_editing("opening_obj").should_be_called() + spatial.get_decomposition("opening").should_be_called().will_return(["opening"]) + spatial.can_contain("container", "door").should_be_called().will_return(True) + spatial.can_contain("container", "opening").should_be_called().will_return(False) + ifc.run("spatial.assign_container", products=["door"], relating_structure="container").should_be_called() + ifc.get_object("door").should_be_called().will_return("door_obj") + ifc.get_object("opening").should_be_called().will_return("opening_obj") + collector.assign("door_obj").should_be_called() + collector.assign("opening_obj").should_be_called() + subject.assign_container(ifc, collector, spatial, container="container", objs=["door_obj", "opening_obj"]) + class TestEnableEditingContainer: def test_run(self, spatial): diff --git a/src/bonsai/test/files/snap-target.ifc b/src/bonsai/test/files/snap-target.ifc new file mode 100644 index 0000000000..0b7132dece --- /dev/null +++ b/src/bonsai/test/files/snap-target.ifc @@ -0,0 +1,91 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition[DesignTransferView]'),'2;1'); +FILE_NAME('snap-target.ifc','2026-06-05T17:45:10-03:00',(''),(''),'IfcOpenShell 0.0.0','Bonsai 0.8.6-alpha260605-24a241a','Nobody'); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCPROJECT('2pZygwkcb1Au$5kgwmW6ZC',$,'My Project',$,$,$,$,(#10,#22),#5); +#2=IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.); +#3=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.); +#4=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.); +#5=IFCUNITASSIGNMENT((#4,#2,#3)); +#6=IFCCARTESIANPOINT((0.,0.,0.)); +#7=IFCDIRECTION((0.,0.,1.)); +#8=IFCDIRECTION((1.,0.,0.)); +#9=IFCAXIS2PLACEMENT3D(#6,#7,#8); +#10=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#9,$); +#11=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#10,$,.MODEL_VIEW.,$); +#12=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Axis','Model',*,*,*,*,#10,$,.GRAPH_VIEW.,$); +#13=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Box','Model',*,*,*,*,#10,$,.MODEL_VIEW.,$); +#14=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#10,$,.SECTION_VIEW.,$); +#15=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#10,$,.ELEVATION_VIEW.,$); +#16=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#10,$,.MODEL_VIEW.,$); +#17=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#10,$,.PLAN_VIEW.,$); +#18=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Profile','Model',*,*,*,*,#10,$,.ELEVATION_VIEW.,$); +#19=IFCCARTESIANPOINT((0.,0.)); +#20=IFCDIRECTION((1.,0.)); +#21=IFCAXIS2PLACEMENT2D(#19,#20); +#22=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Plan',2,1.E-05,#21,$); +#23=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Axis','Plan',*,*,*,*,#22,$,.GRAPH_VIEW.,$); +#24=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Plan',*,*,*,*,#22,$,.PLAN_VIEW.,$); +#25=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Plan',*,*,*,*,#22,$,.PLAN_VIEW.,$); +#26=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Plan',*,*,*,*,#22,$,.REFLECTED_PLAN_VIEW.,$); +#27=IFCSITE('1Lvnr1aSn2OP70jevISY_C',$,'My Site',$,$,#50,$,$,$,$,$,$,$,$); +#33=IFCBUILDING('1NaNtC8Wf6YPU7CZRG8Z8Q',$,'My Building',$,$,#56,$,$,$,$,$,$); +#39=IFCBUILDINGSTOREY('1k9kfaMOr2cvyNvxvoll27',$,'My Storey',$,$,#62,$,$,$,$); +#45=IFCRELAGGREGATES('1plJGwDIHDzwuc7i7gcQQD',$,$,$,#1,(#27)); +#46=IFCCARTESIANPOINT((0.,0.,0.)); +#47=IFCDIRECTION((0.,0.,1.)); +#48=IFCDIRECTION((1.,0.,0.)); +#49=IFCAXIS2PLACEMENT3D(#46,#47,#48); +#50=IFCLOCALPLACEMENT($,#49); +#51=IFCRELAGGREGATES('13KnzD8ZT8bAvAyOQAWiUj',$,$,$,#27,(#33)); +#52=IFCCARTESIANPOINT((0.,0.,0.)); +#53=IFCDIRECTION((0.,0.,1.)); +#54=IFCDIRECTION((1.,0.,0.)); +#55=IFCAXIS2PLACEMENT3D(#52,#53,#54); +#56=IFCLOCALPLACEMENT(#50,#55); +#57=IFCRELAGGREGATES('2Hf4WQLJvE4O43wq$0AZeu',$,$,$,#33,(#39)); +#58=IFCCARTESIANPOINT((0.,0.,0.)); +#59=IFCDIRECTION((0.,0.,1.)); +#60=IFCDIRECTION((1.,0.,0.)); +#61=IFCAXIS2PLACEMENT3D(#58,#59,#60); +#62=IFCLOCALPLACEMENT(#56,#61); +#63=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('setout-point'),$); +#64=IFCPROPERTYSET('27lmSbeAXC08EWkEq8XdUG',$,'EPset_Annotation',$,(#63)); +#65=IFCTYPEPRODUCT('0UrP0fLdD5OwzwD41aRKao',$,'SETOUT-POINT',$,'IfcAnnotation/SYMBOL',(#64),$,$); +#66=IFCCARTESIANPOINTLIST3D(((-8999.9990234375,-8999.9990234375,0.),(8999.9990234375,-8999.9990234375,0.),(-8999.9990234375,8999.9990234375,0.),(8999.9990234375,8999.9990234375,0.))); +#67=IFCINDEXEDPOLYGONALFACE((1,2,4,3)); +#68=IFCPOLYGONALFACESET(#66,$,(#67),$); +#69=IFCSHAPEREPRESENTATION(#11,'Body','Tessellation',(#68)); +#70=IFCBUILDINGELEMENTPROXY('3Avrn7zrPBiA7RUh91ENg9',$,'Plane',$,$,#142,#72,$,.COMPLEX.); +#71=IFCRELCONTAINEDINSPATIALSTRUCTURE('0ftVF1Vf1Dmg$p62mtpWVF',$,$,$,(#123,#70,#108),#39); +#72=IFCPRODUCTDEFINITIONSHAPE($,$,(#69)); +#108=IFCBUILDINGELEMENTPROXY('1lwURx5YX5WAXD5ExqQt31',$,'Plane',$,$,#122,#117,$,.COMPLEX.); +#114=IFCCARTESIANPOINTLIST3D(((-5999.99951171875,-2999.99975585938,0.),(5999.99951171875,-2999.99975585938,0.))); +#115=IFCINDEXEDPOLYCURVE(#114,(IFCLINEINDEX((1,2))),$); +#116=IFCSHAPEREPRESENTATION(#11,'Body','Curve3D',(#115)); +#117=IFCPRODUCTDEFINITIONSHAPE($,$,(#116)); +#118=IFCCARTESIANPOINT((0.,0.,0.)); +#119=IFCDIRECTION((0.,0.,1.)); +#120=IFCDIRECTION((1.,0.,0.)); +#121=IFCAXIS2PLACEMENT3D(#118,#119,#120); +#122=IFCLOCALPLACEMENT(#62,#121); +#123=IFCBUILDINGELEMENTPROXY('2X4YMFxHjANvnkl1Hzqjlq',$,'Plane',$,$,#137,#132,$,.COMPLEX.); +#129=IFCCARTESIANPOINTLIST3D(((3000.,-5999.99951171875,0.),(2999.99951171875,5999.99951171875,0.))); +#130=IFCINDEXEDPOLYCURVE(#129,(IFCLINEINDEX((1,2))),$); +#131=IFCSHAPEREPRESENTATION(#11,'Body','Curve3D',(#130)); +#132=IFCPRODUCTDEFINITIONSHAPE($,$,(#131)); +#133=IFCCARTESIANPOINT((0.,0.,0.)); +#134=IFCDIRECTION((0.,0.,1.)); +#135=IFCDIRECTION((1.,0.,0.)); +#136=IFCAXIS2PLACEMENT3D(#133,#134,#135); +#137=IFCLOCALPLACEMENT(#62,#136); +#138=IFCCARTESIANPOINT((0.,0.,0.)); +#139=IFCDIRECTION((0.,0.,1.)); +#140=IFCDIRECTION((1.,0.,0.)); +#141=IFCAXIS2PLACEMENT3D(#138,#139,#140); +#142=IFCLOCALPLACEMENT(#62,#141); +ENDSEC; +END-ISO-10303-21; diff --git a/src/bonsai/test/files/snap.ifc b/src/bonsai/test/files/snap.ifc new file mode 100644 index 0000000000..763e873985 --- /dev/null +++ b/src/bonsai/test/files/snap.ifc @@ -0,0 +1,1268 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition[DesignTransferView]'),'2;1'); +FILE_NAME('snap.ifc','2026-05-31T21:32:39-03:00',(),(),'IfcOpenShell 0.0.0','Bonsai 0.8.6-alpha260430-a712367','Nobody'); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCPROJECT('0_wwi6gGz9iPW4qKouGP6L',$,'My Project',$,$,$,$,(#14,#26),#9); +#2=IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.); +#3=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.); +#4=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.); +#5=IFCDIMENSIONALEXPONENTS(0,0,0,0,0,0,0); +#6=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); +#7=IFCMEASUREWITHUNIT(IFCREAL(0.0174532925199433),#6); +#8=IFCCONVERSIONBASEDUNIT(#5,.PLANEANGLEUNIT.,'degree',#7); +#9=IFCUNITASSIGNMENT((#4,#2,#8,#3)); +#10=IFCCARTESIANPOINT((0.,0.,0.)); +#11=IFCDIRECTION((0.,0.,1.)); +#12=IFCDIRECTION((1.,0.,0.)); +#13=IFCAXIS2PLACEMENT3D(#10,#11,#12); +#14=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#13,$); +#15=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#14,$,.MODEL_VIEW.,$); +#16=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Axis','Model',*,*,*,*,#14,$,.GRAPH_VIEW.,$); +#17=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Box','Model',*,*,*,*,#14,$,.MODEL_VIEW.,$); +#18=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#14,$,.SECTION_VIEW.,$); +#19=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#14,$,.ELEVATION_VIEW.,$); +#20=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#14,$,.MODEL_VIEW.,$); +#21=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#14,$,.PLAN_VIEW.,$); +#22=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Profile','Model',*,*,*,*,#14,$,.ELEVATION_VIEW.,$); +#23=IFCCARTESIANPOINT((0.,0.)); +#24=IFCDIRECTION((1.,0.)); +#25=IFCAXIS2PLACEMENT2D(#23,#24); +#26=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Plan',2,1.E-05,#25,$); +#27=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Axis','Plan',*,*,*,*,#26,$,.GRAPH_VIEW.,$); +#28=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Plan',*,*,*,*,#26,$,.PLAN_VIEW.,$); +#29=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Plan',*,*,*,*,#26,$,.PLAN_VIEW.,$); +#30=IFCSITE('3q97y5qv978PccYiHqvsaD',$,'My Site',$,$,#53,$,$,$,$,$,$,$,$); +#36=IFCBUILDING('0l2LfU1Jz9IekI6$oJ9I49',$,'My Building',$,$,#59,$,$,$,$,$,$); +#42=IFCBUILDINGSTOREY('2DYJHaYCT6guUOu9i9MdFC',$,'My Storey',$,$,#65,$,$,$,$); +#48=IFCRELAGGREGATES('3gpAinwxbBiefJiHCpBbjc',$,$,$,#1,(#30)); +#49=IFCCARTESIANPOINT((0.,0.,0.)); +#50=IFCDIRECTION((0.,0.,1.)); +#51=IFCDIRECTION((1.,0.,0.)); +#52=IFCAXIS2PLACEMENT3D(#49,#50,#51); +#53=IFCLOCALPLACEMENT($,#52); +#54=IFCRELAGGREGATES('29krqS8irBTfkWPyZmXi4J',$,$,$,#30,(#36)); +#55=IFCCARTESIANPOINT((0.,0.,0.)); +#56=IFCDIRECTION((0.,0.,1.)); +#57=IFCDIRECTION((1.,0.,0.)); +#58=IFCAXIS2PLACEMENT3D(#55,#56,#57); +#59=IFCLOCALPLACEMENT(#53,#58); +#60=IFCRELAGGREGATES('3SejG2dBn0bhb2IesSwnmN',$,$,$,#36,(#42)); +#61=IFCCARTESIANPOINT((0.,0.,0.)); +#62=IFCDIRECTION((0.,0.,1.)); +#63=IFCDIRECTION((1.,0.,0.)); +#64=IFCAXIS2PLACEMENT3D(#61,#62,#63); +#65=IFCLOCALPLACEMENT(#59,#64); +#66=IFCWALLTYPE('2o8NqQQbHA5BeErrgwfr7d',$,'WAL50',$,$,$,$,$,$,.NOTDEFINED.); +#67=IFCRELASSOCIATESMATERIAL('1Q$5DZKr98n82Eitz2dWey',$,$,$,(#66),#70); +#68=IFCMATERIAL('Unknown',$,$); +#69=IFCMATERIALLAYER(#68,50.,$,$,$,$,$); +#70=IFCMATERIALLAYERSET((#69),$,$); +#71=IFCWALLTYPE('3KlIOv_P9A79tkt8D_jq_m',$,'WAL100',$,$,$,$,$,$,.NOTDEFINED.); +#72=IFCRELASSOCIATESMATERIAL('32B9Wx8Z1FxhIN7m77MiHG',$,$,$,(#71),#74); +#73=IFCMATERIALLAYER(#68,100.,$,$,$,$,$); +#74=IFCMATERIALLAYERSET((#73),$,$); +#75=IFCWALLTYPE('2DqrSeel1AGw6h3b$ujafZ',$,'WAL200',$,$,$,$,$,$,.NOTDEFINED.); +#76=IFCRELASSOCIATESMATERIAL('2FeYUpt3D4tAvwgGmwXWZn',$,$,$,(#75),#78); +#77=IFCMATERIALLAYER(#68,200.,$,$,$,$,$); +#78=IFCMATERIALLAYERSET((#77),$,$); +#79=IFCWALLTYPE('2jNdEOFIP1eQb3WZePlFGY',$,'WAL300',$,$,$,$,$,$,.NOTDEFINED.); +#80=IFCRELASSOCIATESMATERIAL('2bFeM6xevAwucuwMlauX77',$,$,$,(#79),#82); +#81=IFCMATERIALLAYER(#68,300.,$,$,$,$,$); +#82=IFCMATERIALLAYERSET((#81),$,$); +#83=IFCCOVERINGTYPE('3310gCgH59w9tu$DfmiCEN',$,'COV10',$,$,$,$,$,$,.NOTDEFINED.); +#84=IFCRELASSOCIATESMATERIAL('3pTNmWd_jBoOWyHc8Yf4qI',$,$,$,(#83),#86); +#85=IFCMATERIALLAYER(#68,10.,$,$,$,$,$); +#86=IFCMATERIALLAYERSET((#85),$,$); +#87=IFCPROPERTYSINGLEVALUE('LayerSetDirection',$,IFCLABEL('AXIS2'),$); +#88=IFCPROPERTYSET('2VZJ9xfdn1NeOY6uEmnnYW',$,'EPset_Parametric',$,(#87)); +#89=IFCCOVERINGTYPE('2sksWFuPzCgeNk1ofXS6PZ',$,'COV20',$,$,(#88),$,$,$,.NOTDEFINED.); +#90=IFCRELASSOCIATESMATERIAL('36rl5NddT1ux6ejkQAkT2z',$,$,$,(#89),#92); +#91=IFCMATERIALLAYER(#68,20.,$,$,$,$,$); +#92=IFCMATERIALLAYERSET((#91),$,$); +#93=IFCPROPERTYSINGLEVALUE('LayerSetDirection',$,IFCLABEL('AXIS3'),$); +#94=IFCPROPERTYSET('0HnrRhcd16Yfbgtci4sqCG',$,'EPset_Parametric',$,(#93)); +#95=IFCCOVERINGTYPE('1uU6rxt95AC91lZ0jTRG6G',$,'COV30',$,$,(#94),$,$,$,.NOTDEFINED.); +#96=IFCRELASSOCIATESMATERIAL('2lFv4xQYr5bwtiSsMTVCgz',$,$,$,(#95),#98); +#97=IFCMATERIALLAYER(#68,30.,$,$,$,$,$); +#98=IFCMATERIALLAYERSET((#97),$,$); +#99=IFCRAMPTYPE('3nonXCVn58jhVV08N8c97B',$,'RAM200',$,$,$,$,$,$,.NOTDEFINED.); +#100=IFCRELASSOCIATESMATERIAL('2NdIrmbUP6bh4x$p_RY31C',$,$,$,(#99),#102); +#101=IFCMATERIALLAYER(#68,200.,$,$,$,$,$); +#102=IFCMATERIALLAYERSET((#101),$,$); +#103=IFCPILETYPE('2uuf5kq5TDDfO9jGN7XIHh',$,'P1',$,$,$,$,$,$,.NOTDEFINED.); +#104=IFCRELASSOCIATESMATERIAL('3hi821Ffj8YR9yFjp226LU',$,$,$,(#103),#107); +#105=IFCCIRCLEPROFILEDEF(.AREA.,$,$,300.); +#106=IFCMATERIALPROFILE($,$,#68,#105,$,$); +#107=IFCMATERIALPROFILESET($,$,(#106),$); +#108=IFCSLABTYPE('2janmz3nH4P9IHaOVvGY_B',$,'FLR150',$,$,$,$,$,$,.NOTDEFINED.); +#109=IFCRELASSOCIATESMATERIAL('0og5Zg4ib67g6XWgOgqBWy',$,$,$,(#108),#111); +#110=IFCMATERIALLAYER(#68,200.,$,$,$,$,$); +#111=IFCMATERIALLAYERSET((#110),$,$); +#112=IFCSLABTYPE('26UKpEaTb9fh4qrqi3Ymzj',$,'FLR250',$,$,$,$,$,$,.NOTDEFINED.); +#113=IFCRELASSOCIATESMATERIAL('1BeBXPqen9TQEE42SxzhjF',$,$,$,(#112),#115); +#114=IFCMATERIALLAYER(#68,300.,$,$,$,$,$); +#115=IFCMATERIALLAYERSET((#114),$,$); +#116=IFCCOLUMNTYPE('1KnWzBBoLCNfJ3_J79LT7d',$,'C1',$,$,$,$,$,$,.NOTDEFINED.); +#117=IFCRELASSOCIATESMATERIAL('1W8OWQjEL05gH5hEpve6Lh',$,$,$,(#116),#120); +#118=IFCRECTANGLEPROFILEDEF(.AREA.,'500x600',$,500.,600.); +#119=IFCMATERIALPROFILE($,$,#68,#118,$,$); +#120=IFCMATERIALPROFILESET($,$,(#119),$); +#121=IFCCOLUMNTYPE('33jPFERfL9bfTeWfTL5kZ7',$,'C2',$,$,$,$,$,$,.NOTDEFINED.); +#122=IFCRELASSOCIATESMATERIAL('3czOrQw2v9BfS0PeQCrzWq',$,$,$,(#121),#125); +#123=IFCCIRCLEHOLLOWPROFILEDEF(.AREA.,'500.0x5.0 CHS',$,250.,5.); +#124=IFCMATERIALPROFILE($,$,#68,#123,$,$); +#125=IFCMATERIALPROFILESET($,$,(#124),$); +#126=IFCCOLUMNTYPE('21YcNdKNj2$95U55yNx1Nw',$,'C3',$,$,$,$,$,$,.NOTDEFINED.); +#127=IFCRELASSOCIATESMATERIAL('3n6Cru3xr6jvDptU9u7QEF',$,$,$,(#126),#130); +#128=IFCRECTANGLEHOLLOWPROFILEDEF(.AREA.,'150x75x2.0 RHS',$,75.,150.,2.,5.,5.); +#129=IFCMATERIALPROFILE($,$,#68,#128,$,$); +#130=IFCMATERIALPROFILESET($,$,(#129),$); +#131=IFCBEAMTYPE('2CsGpD$6nDCxWv9jXTtvq9',$,'B1',$,$,$,$,$,$,.NOTDEFINED.); +#132=IFCRELASSOCIATESMATERIAL('2LJExc74j9rgBSBx51HDPK',$,$,$,(#131),#135); +#133=IFCISHAPEPROFILEDEF(.AREA.,'DEMO-I',$,100.,200.,5.,10.,5.,$,$); +#134=IFCMATERIALPROFILE($,$,#68,#133,$,$); +#135=IFCMATERIALPROFILESET($,$,(#134),$); +#136=IFCBEAMTYPE('32pHN2b0P0awGw$U8PpneO',$,'B2',$,$,$,$,$,$,.NOTDEFINED.); +#137=IFCRELASSOCIATESMATERIAL('2wg8R1Drb2xhQ2Y_pRSh3c',$,$,$,(#136),#140); +#138=IFCCSHAPEPROFILEDEF(.AREA.,'DEMO-C',$,200.,100.,1.5,30.,5.); +#139=IFCMATERIALPROFILE($,$,#68,#138,$,$); +#140=IFCMATERIALPROFILESET($,$,(#139),$); +#141=IFCCARTESIANPOINT((0.,0.,0.)); +#142=IFCDIRECTION((0.,0.,1.)); +#143=IFCDIRECTION((1.,0.,0.)); +#144=IFCAXIS2PLACEMENT3D(#141,#142,#143); +#151=IFCCARTESIANPOINTLIST3D(((899.999976158142,0.,1200.00004768372),(899.999976158142,0.,0.),(0.,0.,1200.00004768372),(0.,0.,0.),(99.9999940395355,0.,99.9999940395355),(99.9999940395355,0.,1100.00002384186),(800.000011920929,0.,1100.00002384186),(800.000011920929,0.,99.9999940395355),(99.9999940395355,19.9999995529652,99.9999940395355),(99.9999940395355,19.9999995529652,1100.00002384186),(800.000011920929,19.9999995529652,1100.00002384186),(800.000011920929,19.9999995529652,99.9999940395355),(99.9999940395355,50.0000007450581,99.9999940395355),(99.9999940395355,50.0000007450581,1100.00002384186),(800.000011920929,50.0000007450581,1100.00002384186),(800.000011920929,50.0000007450581,99.9999940395355),(0.,50.0000007450581,0.),(0.,50.0000007450581,1200.00004768372),(899.999976158142,50.0000007450581,1200.00004768372),(899.999976158142,50.0000007450581,0.),(99.9999940395355,29.9999993294477,99.9999940395355),(99.9999940395355,29.9999993294477,1100.00002384186),(800.000011920929,29.9999993294477,1100.00002384186),(800.000011920929,29.9999993294477,99.9999940395355))); +#152=IFCINDEXEDPOLYGONALFACE((13,17,18,14)); +#153=IFCINDEXEDPOLYGONALFACE((5,6,3,4)); +#154=IFCINDEXEDPOLYGONALFACE((7,8,2,1)); +#155=IFCINDEXEDPOLYGONALFACE((6,7,1,3)); +#156=IFCINDEXEDPOLYGONALFACE((8,5,4,2)); +#157=IFCINDEXEDPOLYGONALFACE((15,19,20,16)); +#158=IFCINDEXEDPOLYGONALFACE((14,18,19,15)); +#159=IFCINDEXEDPOLYGONALFACE((16,20,17,13)); +#160=IFCINDEXEDPOLYGONALFACE((4,17,20,2)); +#161=IFCINDEXEDPOLYGONALFACE((2,20,19,1)); +#162=IFCINDEXEDPOLYGONALFACE((8,16,13,5)); +#163=IFCINDEXEDPOLYGONALFACE((7,15,16,8)); +#164=IFCINDEXEDPOLYGONALFACE((1,19,18,3)); +#165=IFCINDEXEDPOLYGONALFACE((3,18,17,4)); +#166=IFCINDEXEDPOLYGONALFACE((6,14,15,7)); +#167=IFCINDEXEDPOLYGONALFACE((5,13,14,6)); +#168=IFCPOLYGONALFACESET(#151,.T.,(#152,#153,#154,#155,#156,#157,#158,#159,#160,#161,#162,#163,#164,#165,#166,#167),$); +#169=IFCINDEXEDPOLYGONALFACE((12,11,10,9)); +#170=IFCINDEXEDPOLYGONALFACE((24,21,22,23)); +#171=IFCINDEXEDPOLYGONALFACE((11,23,22,10)); +#172=IFCINDEXEDPOLYGONALFACE((10,22,21,9)); +#173=IFCINDEXEDPOLYGONALFACE((9,21,24,12)); +#174=IFCINDEXEDPOLYGONALFACE((12,24,23,11)); +#175=IFCPOLYGONALFACESET(#151,.T.,(#169,#170,#171,#172,#173,#174),$); +#176=IFCSHAPEREPRESENTATION(#15,'Body','Tessellation',(#168,#175)); +#177=IFCREPRESENTATIONMAP(#144,#176); +#178=IFCCARTESIANPOINT((0.,0.,0.)); +#179=IFCDIRECTION((0.,0.,1.)); +#180=IFCDIRECTION((1.,0.,0.)); +#181=IFCAXIS2PLACEMENT3D(#178,#179,#180); +#187=IFCCARTESIANPOINTLIST2D(((100.000023841858,20.0000032782555),(800.000011920929,20.0000032782555),(800.000011920929,30.0000011920929),(100.000023841858,30.0000011920929))); +#188=IFCINDEXEDPOLYCURVE(#187,(IFCLINEINDEX((1,2,3,4,1))),$); +#189=IFCCARTESIANPOINTLIST2D(((899.999976158142,50.0000007450581),(800.000011920929,50.0000007450581),(800.000011920929,0.),(899.999976158142,0.))); +#190=IFCINDEXEDPOLYCURVE(#189,(IFCLINEINDEX((1,2,3,4,1))),$); +#191=IFCCARTESIANPOINTLIST2D(((0.,0.),(100.000023841858,0.),(100.000023841858,50.0000007450581),(0.,50.0000007450581))); +#192=IFCINDEXEDPOLYCURVE(#191,(IFCLINEINDEX((1,2,3,4,1))),$); +#193=IFCCARTESIANPOINTLIST2D(((100.000023841858,50.0000007450581),(800.000011920929,50.0000007450581))); +#194=IFCINDEXEDPOLYCURVE(#193,$,$); +#195=IFCCARTESIANPOINTLIST2D(((100.000023841858,0.),(800.000011920929,0.))); +#196=IFCINDEXEDPOLYCURVE(#195,$,$); +#197=IFCGEOMETRICCURVESET((#188,#190,#192,#194,#196)); +#198=IFCSHAPEREPRESENTATION(#28,'Body','Annotation2D',(#197)); +#199=IFCREPRESENTATIONMAP(#181,#198); +#200=IFCWINDOWTYPE('1Gg9HfZEr69u8SdWfDs2J3',$,'WT01',$,$,$,(#177,#199),$,$,.NOTDEFINED.,.NOTDEFINED.,$,$); +#201=IFCSTYLEDITEM(#168,(#204),'Frame'); +#202=IFCCOLOURRGB($,0.0429765619337559,0.0429765619337559,0.0429765619337559); +#203=IFCSURFACESTYLESHADING(#202,0.); +#204=IFCSURFACESTYLE('Frame',.BOTH.,(#203)); +#205=IFCSTYLEDITEM(#175,(#208),'Glass'); +#206=IFCCOLOURRGB($,0.800000011920929,1.,1.); +#207=IFCSURFACESTYLESHADING(#206,0.799999997019768); +#208=IFCSURFACESTYLE('Glass',.BOTH.,(#207)); +#209=IFCCARTESIANPOINT((0.,0.,0.)); +#210=IFCDIRECTION((0.,0.,1.)); +#211=IFCDIRECTION((1.,0.,0.)); +#212=IFCAXIS2PLACEMENT3D(#209,#210,#211); +#219=IFCCARTESIANPOINTLIST3D(((955.000162124634,0.,2090.00015258789),(955.000162124634,54.9999885261059,2090.00015258789),(970.000028610229,54.9999922513962,2105.00001907349),(0.,99.9999940395355,0.),(970.000028610229,99.9999940395355,2105.00001907349),(39.9999916553497,99.9999940395355,2105.00001907349),(39.9999916553497,54.9999922513962,2105.00001907349),(55.0000071525574,54.9999885261059,2090.00015258789),(55.0000071525574,0.,2090.00015258789),(0.,0.,2145.00021934509),(0.,100.000001490116,2145.00021934509),(44.9999868869781,99.9999940395355,2099.99990463257),(44.9999868869781,59.9999949336052,2099.99990463257),(965.000033378601,59.9999949336052,2099.99990463257),(965.000033378601,99.9999940395355,2099.99990463257),(965.000033378601,99.9999940395355,0.),(965.000033378601,59.9999949336052,0.),(44.9999868869781,59.9999949336052,0.),(44.9999868869781,99.9999940395355,0.),(0.,0.,0.),(55.0000071525574,0.,0.),(55.0000071525574,54.9999922513962,0.),(39.9999916553497,54.9999922513962,0.),(39.9999916553497,99.9999940395355,0.),(1010.00034809113,0.,2145.00021934509),(1010.00034809113,100.000001490116,2145.00021934509),(955.000162124634,0.,0.),(955.000162124634,54.9999885261059,0.),(970.000028610229,54.9999922513962,0.),(970.000028610229,99.9999940395355,0.),(1010.00034809113,0.,0.),(1010.00034809113,100.000001490116,0.))); +#220=IFCINDEXEDPOLYGONALFACE((2,3,29,28)); +#221=IFCINDEXEDPOLYGONALFACE((27,28,29,30,32,31)); +#222=IFCINDEXEDPOLYGONALFACE((7,6,5,3)); +#223=IFCINDEXEDPOLYGONALFACE((8,7,3,2)); +#224=IFCINDEXEDPOLYGONALFACE((23,24,6,7)); +#225=IFCINDEXEDPOLYGONALFACE((21,20,4,24,23,22)); +#226=IFCINDEXEDPOLYGONALFACE((11,10,25,26)); +#227=IFCINDEXEDPOLYGONALFACE((25,1,27,31)); +#228=IFCINDEXEDPOLYGONALFACE((24,4,11,6)); +#229=IFCINDEXEDPOLYGONALFACE((20,21,9,10)); +#230=IFCINDEXEDPOLYGONALFACE((9,8,2,1)); +#231=IFCINDEXEDPOLYGONALFACE((10,9,1,25)); +#232=IFCINDEXEDPOLYGONALFACE((22,23,7,8)); +#233=IFCINDEXEDPOLYGONALFACE((4,20,10,11)); +#234=IFCINDEXEDPOLYGONALFACE((21,22,8,9)); +#235=IFCINDEXEDPOLYGONALFACE((6,11,26,5)); +#236=IFCINDEXEDPOLYGONALFACE((5,26,32,30)); +#237=IFCINDEXEDPOLYGONALFACE((1,2,28,27)); +#238=IFCINDEXEDPOLYGONALFACE((26,25,31,32)); +#239=IFCINDEXEDPOLYGONALFACE((3,5,30,29)); +#240=IFCPOLYGONALFACESET(#219,.T.,(#220,#221,#222,#223,#224,#225,#226,#227,#228,#229,#230,#231,#232,#233,#234,#235,#236,#237,#238,#239),$); +#241=IFCINDEXEDPOLYGONALFACE((17,16,15,14)); +#242=IFCINDEXEDPOLYGONALFACE((12,13,14,15)); +#243=IFCINDEXEDPOLYGONALFACE((16,19,12,15)); +#244=IFCINDEXEDPOLYGONALFACE((19,16,17,18)); +#245=IFCINDEXEDPOLYGONALFACE((19,18,13,12)); +#246=IFCINDEXEDPOLYGONALFACE((18,17,14,13)); +#247=IFCPOLYGONALFACESET(#219,.T.,(#241,#242,#243,#244,#245,#246),$); +#248=IFCSHAPEREPRESENTATION(#15,'Body','Tessellation',(#240,#247)); +#249=IFCREPRESENTATIONMAP(#212,#248); +#250=IFCCARTESIANPOINT((0.,0.,0.)); +#251=IFCDIRECTION((0.,0.,1.)); +#252=IFCDIRECTION((1.,0.,0.)); +#253=IFCAXIS2PLACEMENT3D(#250,#251,#252); +#259=IFCCARTESIANPOINTLIST2D(((964.999914169312,1020.0001001358),(965.000033378601,99.9999940395355),(925.000011920929,99.9999940395355),(924.999952316284,1020.0001001358),(964.999914169312,1020.0001001358),(844.915807247162,1012.12930679321),(726.886332035065,988.651752471924),(612.931072711945,949.969172477722),(504.999756813049,896.743297576904),(404.939234256744,829.885005950928),(314.461469650269,750.538170337677),(235.114604234695,660.060405731201),(168.256282806396,559.999823570251),(115.030474960804,452.068567276001),(76.3478726148605,338.113307952881),(52.8703518211842,220.083817839622),(44.9996180832386,99.999688565731))); +#260=IFCINDEXEDPOLYCURVE(#259,$,$); +#261=IFCCARTESIANPOINTLIST2D(((970.000028610229,54.9999922513962),(955.000162124634,54.9999922513962),(955.000162124634,0.),(1010.00034809113,0.),(1010.00034809113,99.9999940395355),(970.000028610229,99.9999940395355))); +#262=IFCINDEXEDPOLYCURVE(#261,(IFCLINEINDEX((1,2,3,4,5,6,1))),$); +#263=IFCCARTESIANPOINTLIST2D(((0.,0.),(0.,99.9999940395355),(39.9999916553497,99.9999940395355),(39.9999916553497,54.9999922513962),(55.0000071525574,54.9999922513962),(55.0000071525574,0.))); +#264=IFCINDEXEDPOLYCURVE(#263,(IFCLINEINDEX((1,2,3,4,5,6,1))),$); +#265=IFCGEOMETRICCURVESET((#260,#262,#264)); +#266=IFCSHAPEREPRESENTATION(#28,'Body','Annotation2D',(#265)); +#267=IFCREPRESENTATIONMAP(#253,#266); +#268=IFCDOORTYPE('0NBUmPKyT9WecsIeYJrEqg',$,'DT01',$,$,$,(#249,#267),$,$,.NOTDEFINED.,.NOTDEFINED.,$,$); +#269=IFCSTYLEDITEM(#240,(#272),'Frame'); +#270=IFCCOLOURRGB($,0.0429765619337559,0.0429765619337559,0.0429765619337559); +#271=IFCSURFACESTYLESHADING(#270,0.); +#272=IFCSURFACESTYLE('Frame',.BOTH.,(#271)); +#273=IFCSTYLEDITEM(#247,(#276),'Panel'); +#274=IFCCOLOURRGB($,0.184475064277649,0.184475019574165,0.184475019574165); +#275=IFCSURFACESTYLESHADING(#274,0.); +#276=IFCSURFACESTYLE('Panel',.BOTH.,(#275)); +#277=IFCCARTESIANPOINT((0.,0.,0.)); +#278=IFCDIRECTION((0.,0.,1.)); +#279=IFCDIRECTION((1.,0.,0.)); +#280=IFCAXIS2PLACEMENT3D(#277,#278,#279); +#287=IFCCARTESIANPOINTLIST3D(((-75.7642686367035,-12.1694896370173,220.662087202072),(-105.255022644997,-14.1069469973445,230.906546115875),(-164.038479328156,-96.2571799755096,263.201057910919),(-14.9683114141226,-43.4482358396053,228.664547204971),(-42.6693223416805,-12.0228659361601,222.334340214729),(78.8992568850517,-76.7349451780319,173.714026808739),(95.3715369105339,-40.9212671220303,169.86283659935),(-71.9772353768349,-94.9608311057091,171.763256192207),(73.5535696148872,-46.2111458182335,199.328601360321),(-160.245850682259,39.7466160356998,298.533588647842),(106.730677187443,-12.4975387006998,138.676866889),(13.9651391655207,-42.3045344650745,229.461222887039),(96.7235639691353,-14.4418459385633,168.111309409142),(-219.927728176117,-41.4205342531204,239.053592085838),(-198.184996843338,-74.2136090993881,172.668352723122),(-162.167191505432,-43.4498824179173,289.568781852722),(-189.809292554855,-71.6947764158249,281.713783740997),(15.2298724278808,-84.9794447422028,205.268412828445),(-123.513199388981,-45.2961064875126,264.716774225235),(-188.629180192947,-119.135543704033,233.101561665535),(-13.0218090489507,-65.1145428419113,222.954735159874),(-196.876853704453,11.9782146066427,138.698890805244),(43.1601963937283,-45.1620146632195,221.45189344883),(-216.075524687767,-16.599427908659,204.968154430389),(-58.2821778953075,22.4160328507423,331.800371408463),(-190.823614597321,-102.445237338543,260.164886713028),(-43.1380830705166,-99.1964489221573,176.975786685944),(-52.2686094045639,49.4366958737373,351.232975721359),(-89.5938724279404,32.2130136191845,318.689584732056),(13.082567602396,-66.8555349111557,223.062723875046),(-106.145963072777,-41.5130592882633,228.82467508316),(44.8657646775246,-77.6780471205711,203.667193651199),(-103.71295362711,-3.66749544627964,314.385384321213),(-213.60756456852,-16.9711355119944,233.581200242043),(-138.989388942719,-74.9303176999092,265.050023794174),(105.769321322441,-41.5658876299858,138.697892427444),(99.2072820663452,-67.7607133984566,138.679757714272),(-135.680645704269,-40.2409471571445,287.896603345871),(-174.96183514595,-42.5181090831757,74.3281096220016),(-161.954745650291,-12.9314502701163,289.540559053421),(-208.628505468369,-103.418782353401,201.527774333954),(64.0031322836876,-67.7034556865692,197.900995612144),(100.172616541386,12.6537960022688,138.708665966988),(-168.615952134132,48.2185557484627,307.22576379776),(-14.0691194683313,-84.7146064043045,205.532997846603),(70.2492073178291,-102.0467877388,138.582319021225),(-181.213811039925,99.2056727409363,328.065633773804),(-15.2021609246731,-112.156376242638,18.3885656297207),(16.2124074995518,-111.216500401497,21.827794611454),(-133.747041225433,-15.9911345690489,290.624916553497),(-216.561943292618,-70.9330290555954,202.728658914566),(-42.7242144942284,-42.6300838589668,222.017183899879),(-159.124106168747,-73.8818794488907,283.847242593765),(-103.956542909145,15.4779236763716,320.181280374527),(-136.982098221779,-102.321907877922,19.4435473531485),(-183.684900403023,39.6271869540215,295.159220695496),(-107.928916811943,-10.153891518712,291.135489940643),(-103.886745870113,-101.836994290352,18.0104468017817),(-46.1161360144615,-119.219377636909,138.967230916023),(-46.1340732872486,-61.420276761055,215.00451862812),(-211.329713463783,-16.9732719659805,138.692498207092),(-165.825873613358,17.0033983886242,294.365167617798),(-162.926822900772,16.7535953223705,259.086668491364),(44.605728238821,-98.5531806945801,171.382486820221),(-83.4082290530205,3.35463741794229,315.553486347198),(-159.71240401268,24.7225016355515,197.611734271049),(-164.89240527153,105.032727122307,322.820842266083),(-215.148985385895,-46.2404675781727,266.269713640213),(74.162483215332,41.4574705064297,138.786911964417),(14.2031144350767,-105.447888374329,170.478105545044),(14.1690038144588,-13.1895141676068,229.208543896675),(43.3205515146255,-101.634204387665,17.8499221801758),(-194.831639528275,8.55887122452259,198.67131114006),(-190.071240067482,8.37886054068804,263.859361410141),(14.6396514028311,50.3562577068806,171.330958604813),(-46.6328002512455,-78.9417400956154,203.323245048523),(-14.2267476767302,-15.7651714980602,228.64143550396),(-214.272990822792,-70.0500085949898,258.544147014618),(-18.7377445399761,23.4869290143251,211.539566516876),(-169.090524315834,130.419373512268,343.455374240875),(-73.0840340256691,-58.5213899612427,211.252138018608),(-211.533859372139,-42.9056100547314,138.715773820877),(-73.9177912473679,15.4376216232777,210.008263587952),(-73.77789914608,-73.5882744193077,200.627535581589),(-186.267927289009,-121.167339384556,205.986142158508),(89.2870724201202,16.3372419774532,167.569145560265),(-163.796290755272,38.7952998280525,138.641089200974),(-197.594255208969,-74.69642162323,138.668864965439),(-157.580107450485,132.616892457008,328.512966632843),(-73.5077708959579,39.3004417419434,326.341509819031),(-133.432641625404,-80.0390690565109,240.147277712822),(-161.642774939537,-107.512913644314,235.317841172218),(-103.187024593353,15.1489116251469,293.316811323166),(-131.257891654968,-96.2524563074112,88.3080363273621),(-97.7480411529541,54.0151223540306,138.882651925087),(-15.323237515986,-128.71652841568,138.334348797798),(102.820813655853,-72.0862969756126,78.2168358564377),(69.1742300987244,9.61552746593952,196.848139166832),(-78.4864947199821,-104.707300662994,24.4421008974314),(-129.387423396111,-83.7726294994354,201.711267232895),(100.28512775898,14.7631969302893,106.750056147575),(72.5274235010147,-73.3503252267838,16.2904672324657),(90.7945036888123,-63.1996393203735,166.820541024208),(-68.5850381851196,68.8069462776184,138.255223631859),(-43.0277064442635,-107.757613062859,22.122398018837),(102.449595928192,-65.0743395090103,27.8087817132473),(-12.3228346928954,-128.916323184967,51.6869872808456),(13.3168455213308,-126.367673277855,49.7013293206692),(-211.436733603477,-42.5778105854988,171.008050441742),(-135.128378868103,-73.7440511584282,28.781833127141),(-71.3493376970291,-97.4928066134453,48.680767416954),(-14.4545361399651,-107.40352421999,169.533520936966),(-52.0200654864311,-106.458351016045,46.8626022338867),(-38.3422300219536,-121.899470686913,53.7898242473602),(-135.303497314453,4.72360569983721,269.406676292419),(-222.012773156166,-43.5851588845253,201.951056718826),(-150.152832269669,70.6916153430939,296.226799488068),(-205.232128500938,-53.0128739774227,172.492980957031),(81.5067514777184,-84.2671692371368,46.3023483753204),(101.917430758476,-74.4422674179077,51.1590167880058),(-104.162633419037,-76.9466981291771,197.300210595131),(-165.175527334213,100.392691791058,295.828104019165),(62.4474883079529,-91.4158597588539,172.223627567291),(-69.6270391345024,37.1879562735558,345.104366540909),(-129.096910357475,-71.5842396020889,53.2362163066864),(-102.229714393616,-91.8472409248352,50.0270053744316),(32.8243598341942,-62.8630220890045,219.847500324249),(-92.9397568106651,-59.8123446106911,212.814390659332),(-140.351414680481,-65.1696026325226,281.688511371613),(-29.9176927655935,64.6412074565887,345.614969730377),(-210.334226489067,-19.161444157362,170.468419790268),(-189.835593104362,-14.7899463772774,284.663945436478),(-70.6062465906143,-35.3134833276272,219.783633947372),(-196.250692009926,-41.9037826359272,286.000579595566),(-189.289301633835,15.417193993926,167.268991470337),(-165.491297841072,119.253136217594,309.156060218811),(-188.711583614349,-42.2543436288834,85.7931450009346),(-137.549817562103,-17.5594426691532,48.555850982666),(-43.9321398735046,18.8035927712917,209.587976336479),(-166.142821311951,43.8390895724297,269.286632537842),(-100.659042596817,21.2050415575504,210.695147514343),(-165.524810552597,68.1574642658234,275.103896856308),(-131.917878985405,-43.2314537465572,46.9778589904308),(-39.3056124448776,-127.956256270409,80.5243328213692),(-14.8295955732465,-134.464859962463,78.124076128006),(15.6515818089247,-132.012516260147,77.5675550103188),(128.680378198624,-63.8554841279984,48.6980155110359),(11.726126074791,-126.89021229744,138.521879911423),(-104.669205844402,-97.3712056875229,78.6209478974342),(-72.2803771495819,-99.4613841176033,78.2437026500702),(-90.0976955890656,28.9249792695045,304.527103900909),(-131.665915250778,-80.5337652564049,72.9337483644485),(-178.88680100441,12.7522293478251,288.278430700302),(-131.906762719154,21.6084867715836,211.986422538757),(43.8910871744156,44.6652211248875,170.035198330879),(126.842275261879,-62.0891898870468,72.0244571566582),(-181.458547711372,72.0020085573196,305.15855550766),(-105.359517037868,10.6867477297783,222.205132246017),(-75.5681917071342,-105.624243617058,107.75239020586),(-130.771055817604,43.6740666627884,171.749204397202),(-133.024662733078,49.973726272583,138.679206371307),(-116.55567586422,-16.352504491806,262.825727462769),(-192.813113331795,9.62049700319767,228.011801838875),(-99.5994955301285,46.3632792234421,169.919461011887),(-15.3328543528914,77.56557315588,138.280719518661),(-14.9811441078782,54.4508099555969,170.514196157455),(-77.7326822280884,18.9591310918331,297.642737627029),(-42.9378487169743,52.6389256119728,171.193689107895),(-210.668057203293,-93.4961810708046,245.899826288223),(-162.400558590889,19.8477655649185,223.333954811096),(112.556174397469,-41.5905937552452,87.884321808815),(-98.4991043806076,34.1813936829567,196.81504368782),(-125.417664647102,9.07643139362335,292.186677455902),(12.7286352217197,71.5995132923126,138.794869184494),(-184.464573860168,-63.567191362381,91.7578190565109),(-159.845903515816,34.9735803902149,277.037382125854),(-163.954228162766,-73.273241519928,79.649306833744),(-130.220845341682,47.9081235826015,111.017473042011),(-105.627626180649,-103.251308202744,104.907594621181),(-44.7412990033627,-130.966305732727,105.820834636688),(-14.5897325128317,-137.667417526245,107.010833919048),(17.7259147167206,-133.680522441864,110.51332205534),(-204.10780608654,-15.498636290431,265.768945217133),(-163.662612438202,-96.3144749403,108.248025178909),(-133.774682879448,-102.946348488331,108.776144683361),(-152.653515338898,-93.793697655201,11.2244309857488),(-169.374197721481,76.9077241420746,315.95915555954),(-153.37011218071,49.5448186993599,289.855599403381),(-148.65180850029,93.5175195336342,306.516766548157),(-163.774311542511,-100.279614329338,138.708546757698),(-114.786863327026,-34.9755696952343,251.059830188751),(43.5214228928089,-123.003117740154,107.089169323444),(12.2568001970649,23.4032459557056,212.896287441254),(-132.915586233139,-105.148307979107,138.666361570358),(-103.796437382698,-104.18801009655,138.67013156414),(-72.1595510840416,-105.9859842062,138.681977987289),(41.2953048944473,-12.3581402003765,221.496060490608),(-69.7300583124161,50.7166534662247,170.578330755234),(44.1036224365234,-114.852353930473,138.935402035713),(-12.8488391637802,38.8977639377117,196.252673864365),(-124.916173517704,-6.59546442329884,306.106418371201),(-218.161851167679,-71.009561419487,230.814844369888),(-163.197606801987,-97.3011329770088,173.606932163239),(-106.259688735008,-96.0564464330673,167.294099926949),(-134.439319372177,-99.6981337666512,164.969086647034),(-160.570159554482,-110.724151134491,202.919006347656),(-120.365753769875,-5.49432123079896,253.050655126572),(-133.883744478226,10.6024611741304,233.26064646244),(-36.5464128553867,62.771737575531,351.498425006866),(-69.8662772774696,35.7129909098148,305.281817913055),(-135.447904467583,-87.4549821019173,184.239640831947),(-112.891294062138,6.57996907830238,271.908432245255),(-49.9069318175316,49.8133301734924,325.594484806061),(-135.738432407379,-100.006818771362,-7.45058059692383E-06),(12.3523958027363,-101.531967520714,-7.45058059692383E-06),(-102.930329740047,-98.7276136875153,-7.45058059692383E-06),(-158.383101224899,35.3976972401142,167.762398719788),(58.5155189037323,-88.7269079685211,16.9257298111916),(-202.236160635948,-44.0891794860363,107.780121266842),(126.52799487114,-42.4845181405544,31.7913927137852),(44.5115864276886,-111.490845680237,45.2388003468513),(17.8857706487179,35.9265469014645,199.328750371933),(68.5334727168083,-97.8689268231392,53.3365905284882),(138.488471508026,-43.2419404387474,49.3728704750538),(40.6565591692924,62.880277633667,138.536900281906),(87.1811881661415,-87.0387107133865,138.694822788239),(-50.5233928561211,30.0182458013296,313.426643610001),(43.5324311256409,-119.963906705379,79.2121887207031),(72.3142325878143,-100.660108029842,80.1471099257469),(88.0676060914993,-86.207315325737,78.484445810318),(136.276960372925,-40.5644066631794,78.633114695549),(73.5301449894905,46.2804175913334,105.18267005682),(-180.783584713936,120.272636413574,335.98318696022),(-155.802026391029,-42.164009064436,62.2472763061523),(-192.451253533363,-73.2510983943939,112.686090171337),(31.3579067587852,24.0139346569777,208.784699440002),(72.8883668780327,-103.513494133949,107.350297272205),(88.5002017021179,-88.5679498314857,105.739302933216),(100.790202617645,-71.3259652256966,106.83286935091),(109.439946711063,-42.6978133618832,107.300646603107),(-188.64569067955,-16.7884975671768,86.7345333099365),(-70.9428116679192,35.2016389369965,193.65206360817),(-35.7190407812595,61.5072995424271,335.724234580994),(44.7911284863949,14.4118629395962,-7.45058059692383E-06),(36.9860865175724,36.9828194379807,-7.45058059692383E-06),(46.1129434406757,-74.8821049928665,-7.45058059692383E-06),(104.031659662724,-13.5611081495881,14.8804550990462),(98.6066535115242,6.6530667245388,27.1508432924747),(103.960558772087,-42.0542061328888,15.0693515315652),(121.874935925007,-14.7962821647525,28.2622296363115),(69.6230307221413,34.0555869042873,168.976783752441),(72.9203075170517,15.480482019484,22.6278305053711),(-44.5376336574554,74.1409137845039,139.188349246979),(46.685803681612,46.0076108574867,19.2816369235516),(132.462680339813,-14.7683853283525,79.218864440918),(123.972199857235,5.19884005188942,47.1794344484806),(134.83801484108,-13.5693158954382,47.7543026208878),(101.557418704033,15.0842368602753,50.0984787940979),(-151.446789503098,125.798091292381,318.272113800049),(82.6703608036041,23.927254602313,46.4257299900055),(69.3408101797104,43.5765013098717,50.0893704593182),(-42.0871675014496,38.0131863057613,193.471923470497),(-97.1032008528709,61.6641864180565,-7.45058059692383E-06),(-13.0963791161776,64.698226749897,19.7515171021223),(-157.119512557983,8.03167372941971,-7.45058059692383E-06),(113.602519035339,-13.2037419825792,87.9008769989014),(-69.912314414978,66.078893840313,19.1369466483593),(38.9328189194202,35.1467467844486,194.373697042465),(76.8988505005836,42.0413166284561,78.8332372903824),(101.57422721386,13.4498169645667,77.3250162601471),(123.080961406231,3.95354814827442,69.4246292114258),(-211.960434913635,-102.200835943222,224.356546998024),(110.181555151939,-13.6255938559771,109.196342527866),(-102.282598614693,41.4383597671986,19.612405449152),(-172.445297241211,115.39913713932,340.771019458771),(-181.048646569252,112.369157373905,342.96378493309),(72.5264996290207,-15.2853392064571,200.319215655327),(-183.978870511055,70.9394812583923,317.676812410355),(-153.028383851051,-38.4657420217991,-7.45058059692383E-06),(-154.637187719345,-69.1222250461578,-7.45058059692383E-06),(-152.765303850174,-73.8510563969612,15.262059867382),(-153.248697519302,-91.9284746050835,-7.45058059692383E-06),(-161.92090511322,-14.5302480086684,-7.45058059692383E-06),(-161.076262593269,-14.9271814152598,17.3035766929388),(-139.386385679245,-48.0194091796875,20.1432537287474),(-154.07682955265,-33.6258858442307,15.4564278200269),(-141.747921705246,-15.8547051250935,28.8874395191669),(-56.3743449747562,-108.996540307999,73.7379342317581),(-46.1691729724407,89.0766233205795,110.146202147007),(-14.6415047347546,51.2426868081093,-7.45058059692383E-06),(-156.508177518845,8.72325897216797,12.7747664228082),(-93.2494476437569,62.2886717319489,15.7215017825365),(-134.241998195648,18.0515833199024,22.0324043184519),(-75.4619538784027,45.5531552433968,50.9162880480289),(-103.701874613762,27.3517612367868,51.7874732613564),(-131.066977977753,11.5249017253518,52.4038933217525),(-62.931016087532,69.2232176661491,53.6416172981262),(-132.335588335991,32.2872921824455,197.51612842083),(-45.2888980507851,76.0203972458839,47.2172982990742),(-163.926124572754,14.2420912161469,82.3174566030502),(-174.691706895828,-13.5900285094976,73.6509189009666),(-48.6402213573456,84.9898308515549,78.8175389170647),(-68.9510703086853,70.2485665678978,78.4279331564903),(-81.0153111815453,49.1584502160549,74.8984813690186),(-42.9749675095081,61.7619827389717,-7.45058059692383E-06),(34.9937379360199,6.42204098403454,219.141826033592),(-202.323064208031,-12.2631303966045,109.208643436432),(-188.646167516708,14.8954978212714,108.683586120605),(-74.4052901864052,73.5662579536438,106.222227215767),(-161.729156970978,38.1991006433964,108.166508376598),(-104.008600115776,45.3929454088211,-7.45058059692383E-06),(38.8389863073826,70.2219158411026,109.72835123539),(-41.2575826048851,68.8836574554443,20.4634200781584),(-132.600158452988,16.2683837115765,-7.45058059692383E-06),(41.9384241104126,64.3723532557487,48.7342029809952),(-23.0755694210529,90.2970731258392,106.796741485596),(12.2685618698597,50.3091886639595,-7.45058059692383E-06),(42.0029424130917,68.0971890687943,78.9963230490685),(-13.2175851613283,6.25489093363285,222.308561205864),(14.6723045036197,7.23757036030293,223.271667957306),(72.0149055123329,-12.0490025728941,2.31547281146049),(13.688700273633,64.2379224300385,26.2222941964865),(33.619936555624,59.9825419485569,30.1631242036819),(15.6846102327108,72.6122707128525,49.7567467391491),(-13.9973452314734,76.6579210758209,47.4896989762783),(-16.6601836681366,85.7705846428871,79.0435597300529),(12.7416122704744,78.7845030426979,77.6184424757957),(-137.325063347816,22.2998633980751,70.9470063447952),(-103.061355650425,39.2319709062576,82.869827747345),(-133.015736937523,38.7391112744808,90.4415026307106),(-151.931047439575,32.1191623806953,87.8717452287674),(12.5869233161211,80.6632563471794,105.742789804935),(-99.5742082595825,49.370177090168,106.232292950153),(-74.6603757143021,65.6085163354874,-7.45058059692383E-06),(12.2953318059444,17.735980451107,-7.45058059692383E-06),(-14.6934473887086,26.2711010873318,-7.45058059692383E-06),(-42.9374538362026,29.8651698976755,-7.45058059692383E-06),(-103.215932846069,13.7835666537285,-7.45058059692383E-06),(44.4422401487827,-12.9836350679398,-7.45058059692383E-06),(-74.6518895030022,31.6607765853405,-7.45058059692383E-06),(12.3018361628056,-12.7876792103052,-7.45058059692383E-06),(-14.7215090692043,-13.3242877200246,-7.45058059692383E-06),(-101.430043578148,-14.7481001913548,-7.45058059692383E-06),(-42.9213680326939,-15.1002155616879,-7.45058059692383E-06),(-132.630944252014,-13.4387537837029,-7.45058059692383E-06),(-74.6475011110306,-11.1579261720181,-7.45058059692383E-06),(46.1949594318867,-48.3818538486958,-7.45058059692383E-06),(12.3028568923473,-43.1565642356873,-7.45058059692383E-06),(67.6943361759186,-43.9984127879143,2.13921279646456),(-14.7214606404305,-41.9304519891739,-7.45058059692383E-06),(-42.9213680326939,-42.6230616867542,-7.45058059692383E-06),(-134.391859173775,-42.0995727181435,-7.45058059692383E-06),(12.3003236949444,-71.4240521192551,-7.45058059692383E-06),(-14.7217661142349,-71.9940662384033,-7.45058059692383E-06),(-74.6477097272873,-69.8381289839745,-7.45058059692383E-06),(-42.9213680326939,-72.1928924322128,-7.45058059692383E-06),(-101.144231855869,-71.8697011470795,-7.45058059692383E-06),(34.7950644791126,-96.686989068985,-7.45058059692383E-06),(-132.067084312439,-72.0017328858376,-7.45058059692383E-06),(-159.548789262772,-12.5050684437156,61.8688985705376),(-16.9071108102798,-107.485927641392,-7.45058059692383E-06),(-74.6394321322441,-103.576719760895,-7.45058059692383E-06),(-42.8757518529892,-105.996340513229,-7.45058059692383E-06),(-74.6474862098694,-41.8127365410328,-7.45058059692383E-06),(-101.288944482803,-45.6511229276657,-7.45058059692383E-06),(61.871238052845,24.5271548628807,191.577181220055),(-47.0216795802116,41.4715930819511,344.332307577133),(-35.1001992821693,58.2603961229324,352.131396532059),(-43.320570141077,42.2725304961205,325.726985931396),(-33.2878455519676,56.865319609642,334.871053695679),(-78.2285928726196,10.980136692524,334.277510643005),(-61.2197890877724,18.5103937983513,307.83212184906),(-87.6919776201248,26.8637835979462,333.815038204193),(-75.0949084758759,-1.58989988267422,216.51217341423),(-43.2584583759308,0.724630663171411,217.384174466133))); +#288=IFCINDEXEDPOLYGONALFACE((187,278,44)); +#289=IFCINDEXEDPOLYGONALFACE((21,52,60)); +#290=IFCINDEXEDPOLYGONALFACE((91,100,31)); +#291=IFCINDEXEDPOLYGONALFACE((162,19,191)); +#292=IFCINDEXEDPOLYGONALFACE((288,180,159)); +#293=IFCINDEXEDPOLYGONALFACE((241,219,307)); +#294=IFCINDEXEDPOLYGONALFACE((54,93,173)); +#295=IFCINDEXEDPOLYGONALFACE((60,45,21)); +#296=IFCINDEXEDPOLYGONALFACE((58,110,55)); +#297=IFCINDEXEDPOLYGONALFACE((64,18,70)); +#298=IFCINDEXEDPOLYGONALFACE((2,207,162)); +#299=IFCINDEXEDPOLYGONALFACE((10,176,188)); +#300=IFCINDEXEDPOLYGONALFACE((105,114,113)); +#301=IFCINDEXEDPOLYGONALFACE((220,106,249)); +#302=IFCINDEXEDPOLYGONALFACE((252,321,244)); +#303=IFCINDEXEDPOLYGONALFACE((162,57,19)); +#304=IFCINDEXEDPOLYGONALFACE((224,147,220)); +#305=IFCINDEXEDPOLYGONALFACE((90,373,124)); +#306=IFCINDEXEDPOLYGONALFACE((70,199,64)); +#307=IFCINDEXEDPOLYGONALFACE((256,248,258)); +#308=IFCINDEXEDPOLYGONALFACE((115,212,207)); +#309=IFCINDEXEDPOLYGONALFACE((103,36,7)); +#310=IFCINDEXEDPOLYGONALFACE((71,306,320)); +#311=IFCINDEXEDPOLYGONALFACE((297,267,294)); +#312=IFCINDEXEDPOLYGONALFACE((57,50,19)); +#313=IFCINDEXEDPOLYGONALFACE((117,44,188)); +#314=IFCINDEXEDPOLYGONALFACE((62,56,153)); +#315=IFCINDEXEDPOLYGONALFACE((106,147,120)); +#316=IFCINDEXEDPOLYGONALFACE((254,244,245)); +#317=IFCINDEXEDPOLYGONALFACE((208,207,2)); +#318=IFCINDEXEDPOLYGONALFACE((256,257,250)); +#319=IFCINDEXEDPOLYGONALFACE((203,205,211)); +#320=IFCINDEXEDPOLYGONALFACE((56,278,157)); +#321=IFCINDEXEDPOLYGONALFACE((103,7,9)); +#322=IFCINDEXEDPOLYGONALFACE((63,140,176)); +#323=IFCINDEXEDPOLYGONALFACE((15,109,118)); +#324=IFCINDEXEDPOLYGONALFACE((59,159,180)); +#325=IFCINDEXEDPOLYGONALFACE((158,154,208)); +#326=IFCINDEXEDPOLYGONALFACE((300,241,308)); +#327=IFCINDEXEDPOLYGONALFACE((23,32,42)); +#328=IFCINDEXEDPOLYGONALFACE((44,278,56)); +#329=IFCINDEXEDPOLYGONALFACE((189,259,67)); +#330=IFCINDEXEDPOLYGONALFACE((309,304,333)); +#331=IFCINDEXEDPOLYGONALFACE((136,89,259)); +#332=IFCINDEXEDPOLYGONALFACE((31,191,19)); +#333=IFCINDEXEDPOLYGONALFACE((295,304,294)); +#334=IFCINDEXEDPOLYGONALFACE((50,38,19)); +#335=IFCINDEXEDPOLYGONALFACE((44,62,10)); +#336=IFCINDEXEDPOLYGONALFACE((369,25,227)); +#337=IFCINDEXEDPOLYGONALFACE((136,47,233)); +#338=IFCINDEXEDPOLYGONALFACE((33,54,201)); +#339=IFCINDEXEDPOLYGONALFACE((333,304,329)); +#340=IFCINDEXEDPOLYGONALFACE((281,110,285)); +#341=IFCINDEXEDPOLYGONALFACE((275,80,276)); +#342=IFCINDEXEDPOLYGONALFACE((119,106,120)); +#343=IFCINDEXEDPOLYGONALFACE((276,80,233)); +#344=IFCINDEXEDPOLYGONALFACE((232,318,312)); +#345=IFCINDEXEDPOLYGONALFACE((208,63,115)); +#346=IFCINDEXEDPOLYGONALFACE((150,288,159)); +#347=IFCINDEXEDPOLYGONALFACE((286,287,284)); +#348=IFCINDEXEDPOLYGONALFACE((286,285,287)); +#349=IFCINDEXEDPOLYGONALFACE((285,286,279)); +#350=IFCINDEXEDPOLYGONALFACE((239,171,240)); +#351=IFCINDEXEDPOLYGONALFACE((233,47,276)); +#352=IFCINDEXEDPOLYGONALFACE((124,213,90)); +#353=IFCINDEXEDPOLYGONALFACE((157,278,47)); +#354=IFCINDEXEDPOLYGONALFACE((187,47,157)); +#355=IFCINDEXEDPOLYGONALFACE((268,75,222)); +#356=IFCINDEXEDPOLYGONALFACE((101,269,232)); +#357=IFCINDEXEDPOLYGONALFACE((277,7,13)); +#358=IFCINDEXEDPOLYGONALFACE((140,63,74)); +#359=IFCINDEXEDPOLYGONALFACE((140,74,56)); +#360=IFCINDEXEDPOLYGONALFACE((74,153,56)); +#361=IFCINDEXEDPOLYGONALFACE((57,201,50)); +#362=IFCINDEXEDPOLYGONALFACE((320,236,193)); +#363=IFCINDEXEDPOLYGONALFACE((222,236,268)); +#364=IFCINDEXEDPOLYGONALFACE((173,50,201)); +#365=IFCINDEXEDPOLYGONALFACE((299,267,297)); +#366=IFCINDEXEDPOLYGONALFACE((162,212,57)); +#367=IFCINDEXEDPOLYGONALFACE((208,115,207)); +#368=IFCINDEXEDPOLYGONALFACE((267,292,274)); +#369=IFCINDEXEDPOLYGONALFACE((98,197,277)); +#370=IFCINDEXEDPOLYGONALFACE((295,328,329)); +#371=IFCINDEXEDPOLYGONALFACE((158,208,2)); +#372=IFCINDEXEDPOLYGONALFACE((201,57,33)); +#373=IFCINDEXEDPOLYGONALFACE((187,47,278)); +#374=IFCINDEXEDPOLYGONALFACE((241,307,308)); +#375=IFCINDEXEDPOLYGONALFACE((335,317,245)); +#376=IFCINDEXEDPOLYGONALFACE((328,330,329)); +#377=IFCINDEXEDPOLYGONALFACE((84,128,121)); +#378=IFCINDEXEDPOLYGONALFACE((331,330,328)); +#379=IFCINDEXEDPOLYGONALFACE((300,331,328)); +#380=IFCINDEXEDPOLYGONALFACE((129,19,38)); +#381=IFCINDEXEDPOLYGONALFACE((154,298,66)); +#382=IFCINDEXEDPOLYGONALFACE((317,322,323)); +#383=IFCINDEXEDPOLYGONALFACE((302,297,303)); +#384=IFCINDEXEDPOLYGONALFACE((212,93,167)); +#385=IFCINDEXEDPOLYGONALFACE((94,185,184)); +#386=IFCINDEXEDPOLYGONALFACE((211,121,100)); +#387=IFCINDEXEDPOLYGONALFACE((212,173,93)); +#388=IFCINDEXEDPOLYGONALFACE((317,254,245)); +#389=IFCINDEXEDPOLYGONALFACE((51,15,41)); +#390=IFCINDEXEDPOLYGONALFACE((321,339,244)); +#391=IFCINDEXEDPOLYGONALFACE((244,335,245)); +#392=IFCINDEXEDPOLYGONALFACE((211,204,121)); +#393=IFCINDEXEDPOLYGONALFACE((246,72,358)); +#394=IFCINDEXEDPOLYGONALFACE((300,360,301)); +#395=IFCINDEXEDPOLYGONALFACE((234,177,39)); +#396=IFCINDEXEDPOLYGONALFACE((125,152,177)); +#397=IFCINDEXEDPOLYGONALFACE((338,314,311)); +#398=IFCINDEXEDPOLYGONALFACE((149,94,152)); +#399=IFCINDEXEDPOLYGONALFACE((39,175,137)); +#400=IFCINDEXEDPOLYGONALFACE((334,292,267)); +#401=IFCINDEXEDPOLYGONALFACE((343,338,340,346)); +#402=IFCINDEXEDPOLYGONALFACE((283,286,284)); +#403=IFCINDEXEDPOLYGONALFACE((129,16,53)); +#404=IFCINDEXEDPOLYGONALFACE((102,249,106)); +#405=IFCINDEXEDPOLYGONALFACE((197,12,23)); +#406=IFCINDEXEDPOLYGONALFACE((330,310,178)); +#407=IFCINDEXEDPOLYGONALFACE((307,61,22,308)); +#408=IFCINDEXEDPOLYGONALFACE((300,310,331)); +#409=IFCINDEXEDPOLYGONALFACE((205,190,194)); +#410=IFCINDEXEDPOLYGONALFACE((133,2,31)); +#411=IFCINDEXEDPOLYGONALFACE((85,92,20)); +#412=IFCINDEXEDPOLYGONALFACE((360,39,301)); +#413=IFCINDEXEDPOLYGONALFACE((122,47,136)); +#414=IFCINDEXEDPOLYGONALFACE((281,282,186)); +#415=IFCINDEXEDPOLYGONALFACE((2,191,31)); +#416=IFCINDEXEDPOLYGONALFACE((250,249,247)); +#417=IFCINDEXEDPOLYGONALFACE((58,214,216)); +#418=IFCINDEXEDPOLYGONALFACE((234,138,143)); +#419=IFCINDEXEDPOLYGONALFACE((141,298,154)); +#420=IFCINDEXEDPOLYGONALFACE((27,45,76)); +#421=IFCINDEXEDPOLYGONALFACE((146,181,145)); +#422=IFCINDEXEDPOLYGONALFACE((144,181,180)); +#423=IFCINDEXEDPOLYGONALFACE((195,185,179)); +#424=IFCINDEXEDPOLYGONALFACE((228,223,229)); +#425=IFCINDEXEDPOLYGONALFACE((49,358,72)); +#426=IFCINDEXEDPOLYGONALFACE((74,34,183)); +#427=IFCINDEXEDPOLYGONALFACE((221,218,223)); +#428=IFCINDEXEDPOLYGONALFACE((146,107,108)); +#429=IFCINDEXEDPOLYGONALFACE((194,204,205)); +#430=IFCINDEXEDPOLYGONALFACE((352,359,280,279)); +#431=IFCINDEXEDPOLYGONALFACE((46,64,199)); +#432=IFCINDEXEDPOLYGONALFACE((366,86,251)); +#433=IFCINDEXEDPOLYGONALFACE((48,114,105)); +#434=IFCINDEXEDPOLYGONALFACE((198,95,164)); +#435=IFCINDEXEDPOLYGONALFACE((372,65,167)); +#436=IFCINDEXEDPOLYGONALFACE((74,132,153)); +#437=IFCINDEXEDPOLYGONALFACE((21,12,4)); +#438=IFCINDEXEDPOLYGONALFACE((288,111,113)); +#439=IFCINDEXEDPOLYGONALFACE((75,225,174)); +#440=IFCINDEXEDPOLYGONALFACE((166,262,200)); +#441=IFCINDEXEDPOLYGONALFACE((223,230,229)); +#442=IFCINDEXEDPOLYGONALFACE((26,92,3)); +#443=IFCINDEXEDPOLYGONALFACE((219,88,82)); +#444=IFCINDEXEDPOLYGONALFACE((355,357,365,364)); +#445=IFCINDEXEDPOLYGONALFACE((322,325,324)); +#446=IFCINDEXEDPOLYGONALFACE((257,220,250)); +#447=IFCINDEXEDPOLYGONALFACE((289,104,253)); +#448=IFCINDEXEDPOLYGONALFACE((228,108,221)); +#449=IFCINDEXEDPOLYGONALFACE((119,218,102)); +#450=IFCINDEXEDPOLYGONALFACE((367,124,25)); +#451=IFCINDEXEDPOLYGONALFACE((327,325,326)); +#452=IFCINDEXEDPOLYGONALFACE((40,115,63)); +#453=IFCINDEXEDPOLYGONALFACE((321,248,247)); +#454=IFCINDEXEDPOLYGONALFACE((158,83,141)); +#455=IFCINDEXEDPOLYGONALFACE((13,98,277)); +#456=IFCINDEXEDPOLYGONALFACE((352,345,343,365)); +#457=IFCINDEXEDPOLYGONALFACE((5,374,1)); +#458=IFCINDEXEDPOLYGONALFACE((339,347,348,341)); +#459=IFCINDEXEDPOLYGONALFACE((135,87,22)); +#460=IFCINDEXEDPOLYGONALFACE((156,224,231)); +#461=IFCINDEXEDPOLYGONALFACE((163,63,170)); +#462=IFCINDEXEDPOLYGONALFACE((56,142,140)); +#463=IFCINDEXEDPOLYGONALFACE((362,355,356,363)); +#464=IFCINDEXEDPOLYGONALFACE((88,203,15)); +#465=IFCINDEXEDPOLYGONALFACE((24,163,73)); +#466=IFCINDEXEDPOLYGONALFACE((14,78,68)); +#467=IFCINDEXEDPOLYGONALFACE((248,260,258)); +#468=IFCINDEXEDPOLYGONALFACE((78,26,17)); +#469=IFCINDEXEDPOLYGONALFACE((16,17,53)); +#470=IFCINDEXEDPOLYGONALFACE((161,164,95)); +#471=IFCINDEXEDPOLYGONALFACE((291,287,293)); +#472=IFCINDEXEDPOLYGONALFACE((127,18,32)); +#473=IFCINDEXEDPOLYGONALFACE((182,199,148)); +#474=IFCINDEXEDPOLYGONALFACE((319,71,320)); +#475=IFCINDEXEDPOLYGONALFACE((225,232,312)); +#476=IFCINDEXEDPOLYGONALFACE((302,309,289)); +#477=IFCINDEXEDPOLYGONALFACE((13,36,11)); +#478=IFCINDEXEDPOLYGONALFACE((308,87,310)); +#479=IFCINDEXEDPOLYGONALFACE((353,348,347,246)); +#480=IFCINDEXEDPOLYGONALFACE((262,79,200)); +#481=IFCINDEXEDPOLYGONALFACE((131,73,135)); +#482=IFCINDEXEDPOLYGONALFACE((370,213,243)); +#483=IFCINDEXEDPOLYGONALFACE((92,100,91)); +#484=IFCINDEXEDPOLYGONALFACE((89,233,80)); +#485=IFCINDEXEDPOLYGONALFACE((332,165,174)); +#486=IFCINDEXEDPOLYGONALFACE((1,374,2)); +#487=IFCINDEXEDPOLYGONALFACE((28,368,209)); +#488=IFCINDEXEDPOLYGONALFACE((189,136,259)); +#489=IFCINDEXEDPOLYGONALFACE((326,332,327)); +#490=IFCINDEXEDPOLYGONALFACE((117,122,189)); +#491=IFCINDEXEDPOLYGONALFACE((132,16,40)); +#492=IFCINDEXEDPOLYGONALFACE((263,334,311)); +#493=IFCINDEXEDPOLYGONALFACE((134,183,68)); +#494=IFCINDEXEDPOLYGONALFACE((157,122,142)); +#495=IFCINDEXEDPOLYGONALFACE((239,230,97)); +#496=IFCINDEXEDPOLYGONALFACE((180,96,59)); +#497=IFCINDEXEDPOLYGONALFACE((99,113,111)); +#498=IFCINDEXEDPOLYGONALFACE((22,131,135)); +#499=IFCINDEXEDPOLYGONALFACE((321,249,349)); +#500=IFCINDEXEDPOLYGONALFACE((156,120,147)); +#501=IFCINDEXEDPOLYGONALFACE((148,181,182)); +#502=IFCINDEXEDPOLYGONALFACE((152,126,149)); +#503=IFCINDEXEDPOLYGONALFACE((346,340,337,344)); +#504=IFCINDEXEDPOLYGONALFACE((358,215,353,246)); +#505=IFCINDEXEDPOLYGONALFACE((275,89,80)); +#506=IFCINDEXEDPOLYGONALFACE((240,37,239)); +#507=IFCINDEXEDPOLYGONALFACE((14,183,34)); +#508=IFCINDEXEDPOLYGONALFACE((293,295,274)); +#509=IFCINDEXEDPOLYGONALFACE((350,351,344,342)); +#510=IFCINDEXEDPOLYGONALFACE((148,112,96)); +#511=IFCINDEXEDPOLYGONALFACE((313,325,264)); +#512=IFCINDEXEDPOLYGONALFACE((154,170,208)); +#513=IFCINDEXEDPOLYGONALFACE((226,123,46)); +#514=IFCINDEXEDPOLYGONALFACE((351,364,346,344)); +#515=IFCINDEXEDPOLYGONALFACE((355,362,216,357)); +#516=IFCINDEXEDPOLYGONALFACE((349,339,321)); +#517=IFCINDEXEDPOLYGONALFACE((318,324,327)); +#518=IFCINDEXEDPOLYGONALFACE((338,311,334,340)); +#519=IFCINDEXEDPOLYGONALFACE((326,299,302)); +#520=IFCINDEXEDPOLYGONALFACE((112,59,96)); +#521=IFCINDEXEDPOLYGONALFACE((262,198,242)); +#522=IFCINDEXEDPOLYGONALFACE((272,51,41)); +#523=IFCINDEXEDPOLYGONALFACE((318,261,315)); +#524=IFCINDEXEDPOLYGONALFACE((167,57,212)); +#525=IFCINDEXEDPOLYGONALFACE((271,266,255)); +#526=IFCINDEXEDPOLYGONALFACE((218,246,102)); +#527=IFCINDEXEDPOLYGONALFACE((94,179,185)); +#528=IFCINDEXEDPOLYGONALFACE((343,346,364,365)); +#529=IFCINDEXEDPOLYGONALFACE((40,153,132)); +#530=IFCINDEXEDPOLYGONALFACE((345,314,338,343)); +#531=IFCINDEXEDPOLYGONALFACE((8,121,204)); +#532=IFCINDEXEDPOLYGONALFACE((32,64,123)); +#533=IFCINDEXEDPOLYGONALFACE((88,109,82)); +#534=IFCINDEXEDPOLYGONALFACE((133,128,81)); +#535=IFCINDEXEDPOLYGONALFACE((193,319,320)); +#536=IFCINDEXEDPOLYGONALFACE((370,367,369)); +#537=IFCINDEXEDPOLYGONALFACE((6,9,42)); +#538=IFCINDEXEDPOLYGONALFACE((214,186,282)); +#539=IFCINDEXEDPOLYGONALFACE((200,75,166)); +#540=IFCINDEXEDPOLYGONALFACE((375,79,139)); +#541=IFCINDEXEDPOLYGONALFACE((95,309,333)); +#542=IFCINDEXEDPOLYGONALFACE((221,49,72)); +#543=IFCINDEXEDPOLYGONALFACE((36,273,11)); +#544=IFCINDEXEDPOLYGONALFACE((69,155,251)); +#545=IFCINDEXEDPOLYGONALFACE((316,302,289)); +#546=IFCINDEXEDPOLYGONALFACE((297,304,303)); +#547=IFCINDEXEDPOLYGONALFACE((195,159,196)); +#548=IFCINDEXEDPOLYGONALFACE((110,186,55)); +#549=IFCINDEXEDPOLYGONALFACE((323,324,315)); +#550=IFCINDEXEDPOLYGONALFACE((172,83,242)); +#551=IFCINDEXEDPOLYGONALFACE((61,219,82)); +#552=IFCINDEXEDPOLYGONALFACE((283,291,265)); +#553=IFCINDEXEDPOLYGONALFACE((184,175,177)); +#554=IFCINDEXEDPOLYGONALFACE((349,246,347)); +#555=IFCINDEXEDPOLYGONALFACE((174,166,75)); +#556=IFCINDEXEDPOLYGONALFACE((48,363,361)); +#557=IFCINDEXEDPOLYGONALFACE((199,237,46)); +#558=IFCINDEXEDPOLYGONALFACE((164,242,198)); +#559=IFCINDEXEDPOLYGONALFACE((290,317,335,336)); +#560=IFCINDEXEDPOLYGONALFACE((217,298,160)); +#561=IFCINDEXEDPOLYGONALFACE((193,200,79)); +#562=IFCINDEXEDPOLYGONALFACE((253,166,165)); +#563=IFCINDEXEDPOLYGONALFACE((202,116,51)); +#564=IFCINDEXEDPOLYGONALFACE((236,366,268)); +#565=IFCINDEXEDPOLYGONALFACE((170,73,163)); +#566=IFCINDEXEDPOLYGONALFACE((360,328,296)); +#567=IFCINDEXEDPOLYGONALFACE((354,350,348,353)); +#568=IFCINDEXEDPOLYGONALFACE((359,357,216,214)); +#569=IFCINDEXEDPOLYGONALFACE((143,110,125)); +#570=IFCINDEXEDPOLYGONALFACE((265,314,345,283)); +#571=IFCINDEXEDPOLYGONALFACE((252,261,260)); +#572=IFCINDEXEDPOLYGONALFACE((305,337,340,334)); +#573=IFCINDEXEDPOLYGONALFACE((131,116,24)); +#574=IFCINDEXEDPOLYGONALFACE((104,168,253)); +#575=IFCINDEXEDPOLYGONALFACE((126,99,111)); +#576=IFCINDEXEDPOLYGONALFACE((47,275,276)); +#577=IFCINDEXEDPOLYGONALFACE((230,120,97)); +#578=IFCINDEXEDPOLYGONALFACE((279,283,345,352)); +#579=IFCINDEXEDPOLYGONALFACE((67,89,275)); +#580=IFCINDEXEDPOLYGONALFACE((257,271,255)); +#581=IFCINDEXEDPOLYGONALFACE((257,231,224)); +#582=IFCINDEXEDPOLYGONALFACE((316,253,165)); +#583=IFCINDEXEDPOLYGONALFACE((17,3,53)); +#584=IFCINDEXEDPOLYGONALFACE((273,171,266)); +#585=IFCINDEXEDPOLYGONALFACE((260,270,258)); +#586=IFCINDEXEDPOLYGONALFACE((362,58,216)); +#587=IFCINDEXEDPOLYGONALFACE((48,108,107)); +#588=IFCINDEXEDPOLYGONALFACE((57,65,33)); +#589=IFCINDEXEDPOLYGONALFACE((160,172,164)); +#590=IFCINDEXEDPOLYGONALFACE((190,235,184)); +#591=IFCINDEXEDPOLYGONALFACE((354,353,215,361)); +#592=IFCINDEXEDPOLYGONALFACE((258,271,256)); +#593=IFCINDEXEDPOLYGONALFACE((155,366,251)); +#594=IFCINDEXEDPOLYGONALFACE((365,357,359,352)); +#595=IFCINDEXEDPOLYGONALFACE((169,20,26)); +#596=IFCINDEXEDPOLYGONALFACE((312,174,225)); +#597=IFCINDEXEDPOLYGONALFACE((273,43,11)); +#598=IFCINDEXEDPOLYGONALFACE((264,317,290)); +#599=IFCINDEXEDPOLYGONALFACE((287,296,293)); +#600=IFCINDEXEDPOLYGONALFACE((159,149,150)); +#601=IFCINDEXEDPOLYGONALFACE((267,305,334)); +#602=IFCINDEXEDPOLYGONALFACE((206,211,100)); +#603=IFCINDEXEDPOLYGONALFACE((126,150,149)); +#604=IFCINDEXEDPOLYGONALFACE((288,114,144)); +#605=IFCINDEXEDPOLYGONALFACE((266,101,273)); +#606=IFCINDEXEDPOLYGONALFACE((123,42,32)); +#607=IFCINDEXEDPOLYGONALFACE((255,171,231)); +#608=IFCINDEXEDPOLYGONALFACE((34,116,14)); +#609=IFCINDEXEDPOLYGONALFACE((91,3,92)); +#610=IFCINDEXEDPOLYGONALFACE((287,143,138)); +#611=IFCINDEXEDPOLYGONALFACE((77,12,71)); +#612=IFCINDEXEDPOLYGONALFACE((95,178,161)); +#613=IFCINDEXEDPOLYGONALFACE((285,280,281)); +#614=IFCINDEXEDPOLYGONALFACE((242,139,262)); +#615=IFCINDEXEDPOLYGONALFACE((332,318,327)); +#616=IFCINDEXEDPOLYGONALFACE((226,239,37)); +#617=IFCINDEXEDPOLYGONALFACE((175,219,137)); +#618=IFCINDEXEDPOLYGONALFACE((177,94,184)); +#619=IFCINDEXEDPOLYGONALFACE((103,226,37)); +#620=IFCINDEXEDPOLYGONALFACE((372,371,65)); +#621=IFCINDEXEDPOLYGONALFACE((341,335,244,339)); +#622=IFCINDEXEDPOLYGONALFACE((101,69,43)); +#623=IFCINDEXEDPOLYGONALFACE((146,192,182)); +#624=IFCINDEXEDPOLYGONALFACE((52,77,5)); +#625=IFCINDEXEDPOLYGONALFACE((133,60,52)); +#626=IFCINDEXEDPOLYGONALFACE((28,243,213)); +#627=IFCINDEXEDPOLYGONALFACE((110,126,125)); +#628=IFCINDEXEDPOLYGONALFACE((140,188,176)); +#629=IFCINDEXEDPOLYGONALFACE((341,342,336,335)); +#630=IFCINDEXEDPOLYGONALFACE((82,131,61)); +#631=IFCINDEXEDPOLYGONALFACE((290,336,337,305)); +#632=IFCINDEXEDPOLYGONALFACE((109,51,116)); +#633=IFCINDEXEDPOLYGONALFACE((210,29,90)); +#634=IFCINDEXEDPOLYGONALFACE((45,30,21)); +#635=IFCINDEXEDPOLYGONALFACE((204,196,8)); +#636=IFCINDEXEDPOLYGONALFACE((229,238,237)); +#637=IFCINDEXEDPOLYGONALFACE((161,217,160)); +#638=IFCINDEXEDPOLYGONALFACE((305,264,290)); +#639=IFCINDEXEDPOLYGONALFACE((84,60,81)); +#640=IFCINDEXEDPOLYGONALFACE((185,190,184)); +#641=IFCINDEXEDPOLYGONALFACE((5,133,52)); +#642=IFCINDEXEDPOLYGONALFACE((189,187,117)); +#643=IFCINDEXEDPOLYGONALFACE((226,237,238)); +#644=IFCINDEXEDPOLYGONALFACE((23,277,197)); +#645=IFCINDEXEDPOLYGONALFACE((76,8,27)); +#646=IFCINDEXEDPOLYGONALFACE((294,274,295)); +#647=IFCINDEXEDPOLYGONALFACE((145,114,107)); +#648=IFCINDEXEDPOLYGONALFACE((188,44,10)); +#649=IFCINDEXEDPOLYGONALFACE((41,203,85)); +#650=IFCINDEXEDPOLYGONALFACE((13,43,86)); +#651=IFCINDEXEDPOLYGONALFACE((355,364,351,356)); +#652=IFCINDEXEDPOLYGONALFACE((234,125,177)); +#653=IFCINDEXEDPOLYGONALFACE((40,38,50)); +#654=IFCINDEXEDPOLYGONALFACE((272,85,20)); +#655=IFCINDEXEDPOLYGONALFACE((215,48,361)); +#656=IFCINDEXEDPOLYGONALFACE((39,241,301)); +#657=IFCINDEXEDPOLYGONALFACE((311,292,263)); +#658=IFCINDEXEDPOLYGONALFACE((69,86,43)); +#659=IFCINDEXEDPOLYGONALFACE((310,161,178)); +#660=IFCINDEXEDPOLYGONALFACE((202,169,78)); +#661=IFCINDEXEDPOLYGONALFACE((248,250,247)); +#662=IFCINDEXEDPOLYGONALFACE((296,138,360)); +#663=IFCINDEXEDPOLYGONALFACE((42,9,23)); +#664=IFCINDEXEDPOLYGONALFACE((203,206,85)); +#665=IFCINDEXEDPOLYGONALFACE((202,272,169)); +#666=IFCINDEXEDPOLYGONALFACE((342,344,337,336)); +#667=IFCINDEXEDPOLYGONALFACE((129,35,19)); +#668=IFCINDEXEDPOLYGONALFACE((2,162,191)); +#669=IFCINDEXEDPOLYGONALFACE((366,306,98)); +#670=IFCINDEXEDPOLYGONALFACE((361,363,356,354)); +#671=IFCINDEXEDPOLYGONALFACE((68,17,134)); +#672=IFCINDEXEDPOLYGONALFACE((54,173,201)); +#673=IFCINDEXEDPOLYGONALFACE((210,167,151)); +#674=IFCINDEXEDPOLYGONALFACE((156,171,97)); +#675=IFCINDEXEDPOLYGONALFACE((54,151,93)); +#676=IFCINDEXEDPOLYGONALFACE((59,8,196)); +#677=IFCINDEXEDPOLYGONALFACE((213,210,90)); +#678=IFCINDEXEDPOLYGONALFACE((54,371,373)); +#679=IFCINDEXEDPOLYGONALFACE((130,243,209)); +#680=IFCINDEXEDPOLYGONALFACE((359,214,282,280)); +#681=IFCINDEXEDPOLYGONALFACE((142,117,188)); +#682=IFCINDEXEDPOLYGONALFACE((28,367,368)); +#683=IFCINDEXEDPOLYGONALFACE((237,228,229)); +#684=IFCINDEXEDPOLYGONALFACE((362,105,99)); +#685=IFCINDEXEDPOLYGONALFACE((291,314,265)); +#686=IFCINDEXEDPOLYGONALFACE((45,70,18)); +#687=IFCINDEXEDPOLYGONALFACE((210,372,167)); +#688=IFCINDEXEDPOLYGONALFACE((62,63,176)); +#689=IFCINDEXEDPOLYGONALFACE((91,19,35)); +#690=IFCINDEXEDPOLYGONALFACE((206,203,211)); +#691=IFCINDEXEDPOLYGONALFACE((269,260,261)); +#692=IFCINDEXEDPOLYGONALFACE((53,35,129)); +#693=IFCINDEXEDPOLYGONALFACE((54,29,151)); +#694=IFCINDEXEDPOLYGONALFACE((130,368,370)); +#695=IFCINDEXEDPOLYGONALFACE((67,187,189)); +#696=IFCINDEXEDPOLYGONALFACE((371,25,124)); +#697=IFCINDEXEDPOLYGONALFACE((130,209,368)); +#698=IFCINDEXEDPOLYGONALFACE((243,130,370)); +#699=IFCINDEXEDPOLYGONALFACE((213,227,210)); +#700=IFCINDEXEDPOLYGONALFACE((227,372,210)); +#701=IFCINDEXEDPOLYGONALFACE((167,93,151)); +#702=IFCINDEXEDPOLYGONALFACE((372,227,25)); +#703=IFCINDEXEDPOLYGONALFACE((373,29,54)); +#704=IFCINDEXEDPOLYGONALFACE((213,369,227)); +#705=IFCINDEXEDPOLYGONALFACE((371,124,373)); +#706=IFCINDEXEDPOLYGONALFACE((341,348,350,342)); +#707=IFCINDEXEDPOLYGONALFACE((135,66,217)); +#708=IFCINDEXEDPOLYGONALFACE((65,371,33)); +#709=IFCINDEXEDPOLYGONALFACE((350,354,356,351)); +#710=IFCINDEXEDPOLYGONALFACE((333,330,178)); +#711=IFCINDEXEDPOLYGONALFACE((315,254,323)); +#712=IFCINDEXEDPOLYGONALFACE((127,12,30)); +#713=IFCINDEXEDPOLYGONALFACE((100,128,31)); +#714=IFCINDEXEDPOLYGONALFACE((319,5,77)); +#715=IFCINDEXEDPOLYGONALFACE((374,158,2)); +#716=IFCINDEXEDPOLYGONALFACE((375,83,374)); +#717=IFCINDEXEDPOLYGONALFACE((314,274,311)); +#718=IFCINDEXEDPOLYGONALFACE((21,4,52)); +#719=IFCINDEXEDPOLYGONALFACE((288,144,180)); +#720=IFCINDEXEDPOLYGONALFACE((241,137,219)); +#721=IFCINDEXEDPOLYGONALFACE((60,76,45)); +#722=IFCINDEXEDPOLYGONALFACE((10,62,176)); +#723=IFCINDEXEDPOLYGONALFACE((220,147,106)); +#724=IFCINDEXEDPOLYGONALFACE((90,29,373)); +#725=IFCINDEXEDPOLYGONALFACE((70,148,199)); +#726=IFCINDEXEDPOLYGONALFACE((103,37,36)); +#727=IFCINDEXEDPOLYGONALFACE((71,197,306)); +#728=IFCINDEXEDPOLYGONALFACE((117,187,44)); +#729=IFCINDEXEDPOLYGONALFACE((62,44,56)); +#730=IFCINDEXEDPOLYGONALFACE((254,252,244)); +#731=IFCINDEXEDPOLYGONALFACE((59,196,159)); +#732=IFCINDEXEDPOLYGONALFACE((158,141,154)); +#733=IFCINDEXEDPOLYGONALFACE((300,301,241)); +#734=IFCINDEXEDPOLYGONALFACE((23,127,32)); +#735=IFCINDEXEDPOLYGONALFACE((309,303,304)); +#736=IFCINDEXEDPOLYGONALFACE((295,329,304)); +#737=IFCINDEXEDPOLYGONALFACE((369,367,25)); +#738=IFCINDEXEDPOLYGONALFACE((119,102,106)); +#739=IFCINDEXEDPOLYGONALFACE((232,269,318)); +#740=IFCINDEXEDPOLYGONALFACE((208,170,63)); +#741=IFCINDEXEDPOLYGONALFACE((239,97,171)); +#742=IFCINDEXEDPOLYGONALFACE((124,28,213)); +#743=IFCINDEXEDPOLYGONALFACE((268,155,75)); +#744=IFCINDEXEDPOLYGONALFACE((101,270,269)); +#745=IFCINDEXEDPOLYGONALFACE((277,9,7)); +#746=IFCINDEXEDPOLYGONALFACE((320,306,236)); +#747=IFCINDEXEDPOLYGONALFACE((222,193,236)); +#748=IFCINDEXEDPOLYGONALFACE((173,115,50)); +#749=IFCINDEXEDPOLYGONALFACE((299,313,267)); +#750=IFCINDEXEDPOLYGONALFACE((162,207,212)); +#751=IFCINDEXEDPOLYGONALFACE((98,306,197)); +#752=IFCINDEXEDPOLYGONALFACE((295,296,328)); +#753=IFCINDEXEDPOLYGONALFACE((84,81,128)); +#754=IFCINDEXEDPOLYGONALFACE((302,299,297)); +#755=IFCINDEXEDPOLYGONALFACE((212,115,173)); +#756=IFCINDEXEDPOLYGONALFACE((317,323,254)); +#757=IFCINDEXEDPOLYGONALFACE((211,205,204)); +#758=IFCINDEXEDPOLYGONALFACE((39,177,175)); +#759=IFCINDEXEDPOLYGONALFACE((334,263,292)); +#760=IFCINDEXEDPOLYGONALFACE((283,279,286)); +#761=IFCINDEXEDPOLYGONALFACE((129,38,16)); +#762=IFCINDEXEDPOLYGONALFACE((102,349,249)); +#763=IFCINDEXEDPOLYGONALFACE((197,71,12)); +#764=IFCINDEXEDPOLYGONALFACE((330,331,310)); +#765=IFCINDEXEDPOLYGONALFACE((300,308,310)); +#766=IFCINDEXEDPOLYGONALFACE((205,203,190)); +#767=IFCINDEXEDPOLYGONALFACE((133,1,2)); +#768=IFCINDEXEDPOLYGONALFACE((85,206,92)); +#769=IFCINDEXEDPOLYGONALFACE((360,234,39)); +#770=IFCINDEXEDPOLYGONALFACE((122,157,47)); +#771=IFCINDEXEDPOLYGONALFACE((281,280,282)); +#772=IFCINDEXEDPOLYGONALFACE((250,220,249)); +#773=IFCINDEXEDPOLYGONALFACE((58,55,214)); +#774=IFCINDEXEDPOLYGONALFACE((234,360,138)); +#775=IFCINDEXEDPOLYGONALFACE((141,172,298)); +#776=IFCINDEXEDPOLYGONALFACE((27,112,45)); +#777=IFCINDEXEDPOLYGONALFACE((146,182,181)); +#778=IFCINDEXEDPOLYGONALFACE((144,145,181)); +#779=IFCINDEXEDPOLYGONALFACE((195,194,185)); +#780=IFCINDEXEDPOLYGONALFACE((228,221,223)); +#781=IFCINDEXEDPOLYGONALFACE((49,215,358)); +#782=IFCINDEXEDPOLYGONALFACE((74,163,34)); +#783=IFCINDEXEDPOLYGONALFACE((221,72,218)); +#784=IFCINDEXEDPOLYGONALFACE((146,145,107)); +#785=IFCINDEXEDPOLYGONALFACE((194,195,204)); +#786=IFCINDEXEDPOLYGONALFACE((46,123,64)); +#787=IFCINDEXEDPOLYGONALFACE((366,98,86)); +#788=IFCINDEXEDPOLYGONALFACE((48,107,114)); +#789=IFCINDEXEDPOLYGONALFACE((198,104,95)); +#790=IFCINDEXEDPOLYGONALFACE((74,183,132)); +#791=IFCINDEXEDPOLYGONALFACE((21,30,12)); +#792=IFCINDEXEDPOLYGONALFACE((288,150,111)); +#793=IFCINDEXEDPOLYGONALFACE((75,155,225)); +#794=IFCINDEXEDPOLYGONALFACE((166,168,262)); +#795=IFCINDEXEDPOLYGONALFACE((223,119,230)); +#796=IFCINDEXEDPOLYGONALFACE((26,20,92)); +#797=IFCINDEXEDPOLYGONALFACE((219,235,88)); +#798=IFCINDEXEDPOLYGONALFACE((322,264,325)); +#799=IFCINDEXEDPOLYGONALFACE((257,224,220)); +#800=IFCINDEXEDPOLYGONALFACE((289,309,104)); +#801=IFCINDEXEDPOLYGONALFACE((228,146,108)); +#802=IFCINDEXEDPOLYGONALFACE((119,223,218)); +#803=IFCINDEXEDPOLYGONALFACE((367,28,124)); +#804=IFCINDEXEDPOLYGONALFACE((327,324,325)); +#805=IFCINDEXEDPOLYGONALFACE((40,50,115)); +#806=IFCINDEXEDPOLYGONALFACE((321,252,248)); +#807=IFCINDEXEDPOLYGONALFACE((13,86,98)); +#808=IFCINDEXEDPOLYGONALFACE((5,375,374)); +#809=IFCINDEXEDPOLYGONALFACE((135,217,87)); +#810=IFCINDEXEDPOLYGONALFACE((156,147,224)); +#811=IFCINDEXEDPOLYGONALFACE((163,74,63)); +#812=IFCINDEXEDPOLYGONALFACE((56,157,142)); +#813=IFCINDEXEDPOLYGONALFACE((88,190,203)); +#814=IFCINDEXEDPOLYGONALFACE((24,34,163)); +#815=IFCINDEXEDPOLYGONALFACE((14,202,78)); +#816=IFCINDEXEDPOLYGONALFACE((248,252,260)); +#817=IFCINDEXEDPOLYGONALFACE((78,169,26)); +#818=IFCINDEXEDPOLYGONALFACE((16,134,17)); +#819=IFCINDEXEDPOLYGONALFACE((161,160,164)); +#820=IFCINDEXEDPOLYGONALFACE((291,284,287)); +#821=IFCINDEXEDPOLYGONALFACE((127,30,18)); +#822=IFCINDEXEDPOLYGONALFACE((182,192,199)); +#823=IFCINDEXEDPOLYGONALFACE((319,77,71)); +#824=IFCINDEXEDPOLYGONALFACE((225,69,232)); +#825=IFCINDEXEDPOLYGONALFACE((302,303,309)); +#826=IFCINDEXEDPOLYGONALFACE((13,7,36)); +#827=IFCINDEXEDPOLYGONALFACE((308,22,87)); +#828=IFCINDEXEDPOLYGONALFACE((262,139,79)); +#829=IFCINDEXEDPOLYGONALFACE((131,24,73)); +#830=IFCINDEXEDPOLYGONALFACE((370,369,213)); +#831=IFCINDEXEDPOLYGONALFACE((92,206,100)); +#832=IFCINDEXEDPOLYGONALFACE((89,136,233)); +#833=IFCINDEXEDPOLYGONALFACE((332,316,165)); +#834=IFCINDEXEDPOLYGONALFACE((189,122,136)); +#835=IFCINDEXEDPOLYGONALFACE((326,316,332)); +#836=IFCINDEXEDPOLYGONALFACE((117,142,122)); +#837=IFCINDEXEDPOLYGONALFACE((132,134,16)); +#838=IFCINDEXEDPOLYGONALFACE((134,132,183)); +#839=IFCINDEXEDPOLYGONALFACE((239,238,230)); +#840=IFCINDEXEDPOLYGONALFACE((180,181,96)); +#841=IFCINDEXEDPOLYGONALFACE((99,105,113)); +#842=IFCINDEXEDPOLYGONALFACE((22,61,131)); +#843=IFCINDEXEDPOLYGONALFACE((321,247,249)); +#844=IFCINDEXEDPOLYGONALFACE((156,97,120)); +#845=IFCINDEXEDPOLYGONALFACE((148,96,181)); +#846=IFCINDEXEDPOLYGONALFACE((152,125,126)); +#847=IFCINDEXEDPOLYGONALFACE((240,36,37)); +#848=IFCINDEXEDPOLYGONALFACE((14,68,183)); +#849=IFCINDEXEDPOLYGONALFACE((293,296,295)); +#850=IFCINDEXEDPOLYGONALFACE((148,70,112)); +#851=IFCINDEXEDPOLYGONALFACE((313,299,325)); +#852=IFCINDEXEDPOLYGONALFACE((154,66,170)); +#853=IFCINDEXEDPOLYGONALFACE((226,6,123)); +#854=IFCINDEXEDPOLYGONALFACE((349,347,339)); +#855=IFCINDEXEDPOLYGONALFACE((318,315,324)); +#856=IFCINDEXEDPOLYGONALFACE((326,325,299)); +#857=IFCINDEXEDPOLYGONALFACE((112,27,59)); +#858=IFCINDEXEDPOLYGONALFACE((262,168,198)); +#859=IFCINDEXEDPOLYGONALFACE((272,202,51)); +#860=IFCINDEXEDPOLYGONALFACE((318,269,261)); +#861=IFCINDEXEDPOLYGONALFACE((167,65,57)); +#862=IFCINDEXEDPOLYGONALFACE((271,270,266)); +#863=IFCINDEXEDPOLYGONALFACE((218,72,246)); +#864=IFCINDEXEDPOLYGONALFACE((94,149,179)); +#865=IFCINDEXEDPOLYGONALFACE((40,62,153)); +#866=IFCINDEXEDPOLYGONALFACE((8,84,121)); +#867=IFCINDEXEDPOLYGONALFACE((32,18,64)); +#868=IFCINDEXEDPOLYGONALFACE((88,15,109)); +#869=IFCINDEXEDPOLYGONALFACE((133,31,128)); +#870=IFCINDEXEDPOLYGONALFACE((193,79,319)); +#871=IFCINDEXEDPOLYGONALFACE((370,368,367)); +#872=IFCINDEXEDPOLYGONALFACE((6,103,9)); +#873=IFCINDEXEDPOLYGONALFACE((214,55,186)); +#874=IFCINDEXEDPOLYGONALFACE((200,222,75)); +#875=IFCINDEXEDPOLYGONALFACE((375,319,79)); +#876=IFCINDEXEDPOLYGONALFACE((95,104,309)); +#877=IFCINDEXEDPOLYGONALFACE((221,108,49)); +#878=IFCINDEXEDPOLYGONALFACE((36,240,273)); +#879=IFCINDEXEDPOLYGONALFACE((69,225,155)); +#880=IFCINDEXEDPOLYGONALFACE((316,326,302)); +#881=IFCINDEXEDPOLYGONALFACE((297,294,304)); +#882=IFCINDEXEDPOLYGONALFACE((195,179,159)); +#883=IFCINDEXEDPOLYGONALFACE((110,281,186)); +#884=IFCINDEXEDPOLYGONALFACE((323,322,324)); +#885=IFCINDEXEDPOLYGONALFACE((172,141,83)); +#886=IFCINDEXEDPOLYGONALFACE((61,307,219)); +#887=IFCINDEXEDPOLYGONALFACE((283,284,291)); +#888=IFCINDEXEDPOLYGONALFACE((184,235,175)); +#889=IFCINDEXEDPOLYGONALFACE((349,102,246)); +#890=IFCINDEXEDPOLYGONALFACE((174,165,166)); +#891=IFCINDEXEDPOLYGONALFACE((48,105,363)); +#892=IFCINDEXEDPOLYGONALFACE((199,192,237)); +#893=IFCINDEXEDPOLYGONALFACE((164,172,242)); +#894=IFCINDEXEDPOLYGONALFACE((217,66,298)); +#895=IFCINDEXEDPOLYGONALFACE((193,222,200)); +#896=IFCINDEXEDPOLYGONALFACE((253,168,166)); +#897=IFCINDEXEDPOLYGONALFACE((202,14,116)); +#898=IFCINDEXEDPOLYGONALFACE((236,306,366)); +#899=IFCINDEXEDPOLYGONALFACE((170,66,73)); +#900=IFCINDEXEDPOLYGONALFACE((360,300,328)); +#901=IFCINDEXEDPOLYGONALFACE((143,285,110)); +#902=IFCINDEXEDPOLYGONALFACE((252,254,261)); +#903=IFCINDEXEDPOLYGONALFACE((131,109,116)); +#904=IFCINDEXEDPOLYGONALFACE((104,198,168)); +#905=IFCINDEXEDPOLYGONALFACE((126,58,99)); +#906=IFCINDEXEDPOLYGONALFACE((47,67,275)); +#907=IFCINDEXEDPOLYGONALFACE((230,119,120)); +#908=IFCINDEXEDPOLYGONALFACE((67,259,89)); +#909=IFCINDEXEDPOLYGONALFACE((257,256,271)); +#910=IFCINDEXEDPOLYGONALFACE((257,255,231)); +#911=IFCINDEXEDPOLYGONALFACE((316,289,253)); +#912=IFCINDEXEDPOLYGONALFACE((17,26,3)); +#913=IFCINDEXEDPOLYGONALFACE((273,240,171)); +#914=IFCINDEXEDPOLYGONALFACE((362,99,58)); +#915=IFCINDEXEDPOLYGONALFACE((48,49,108)); +#916=IFCINDEXEDPOLYGONALFACE((160,298,172)); +#917=IFCINDEXEDPOLYGONALFACE((190,88,235)); +#918=IFCINDEXEDPOLYGONALFACE((258,270,271)); +#919=IFCINDEXEDPOLYGONALFACE((155,268,366)); +#920=IFCINDEXEDPOLYGONALFACE((169,272,20)); +#921=IFCINDEXEDPOLYGONALFACE((312,332,174)); +#922=IFCINDEXEDPOLYGONALFACE((273,101,43)); +#923=IFCINDEXEDPOLYGONALFACE((264,322,317)); +#924=IFCINDEXEDPOLYGONALFACE((287,138,296)); +#925=IFCINDEXEDPOLYGONALFACE((159,179,149)); +#926=IFCINDEXEDPOLYGONALFACE((267,313,305)); +#927=IFCINDEXEDPOLYGONALFACE((126,111,150)); +#928=IFCINDEXEDPOLYGONALFACE((288,113,114)); +#929=IFCINDEXEDPOLYGONALFACE((266,270,101)); +#930=IFCINDEXEDPOLYGONALFACE((123,6,42)); +#931=IFCINDEXEDPOLYGONALFACE((255,266,171)); +#932=IFCINDEXEDPOLYGONALFACE((34,24,116)); +#933=IFCINDEXEDPOLYGONALFACE((91,35,3)); +#934=IFCINDEXEDPOLYGONALFACE((287,285,143)); +#935=IFCINDEXEDPOLYGONALFACE((77,4,12)); +#936=IFCINDEXEDPOLYGONALFACE((95,333,178)); +#937=IFCINDEXEDPOLYGONALFACE((285,279,280)); +#938=IFCINDEXEDPOLYGONALFACE((242,83,139)); +#939=IFCINDEXEDPOLYGONALFACE((332,312,318)); +#940=IFCINDEXEDPOLYGONALFACE((226,238,239)); +#941=IFCINDEXEDPOLYGONALFACE((175,235,219)); +#942=IFCINDEXEDPOLYGONALFACE((177,152,94)); +#943=IFCINDEXEDPOLYGONALFACE((103,6,226)); +#944=IFCINDEXEDPOLYGONALFACE((372,25,371)); +#945=IFCINDEXEDPOLYGONALFACE((101,232,69)); +#946=IFCINDEXEDPOLYGONALFACE((146,228,192)); +#947=IFCINDEXEDPOLYGONALFACE((52,4,77)); +#948=IFCINDEXEDPOLYGONALFACE((133,81,60)); +#949=IFCINDEXEDPOLYGONALFACE((28,209,243)); +#950=IFCINDEXEDPOLYGONALFACE((110,58,126)); +#951=IFCINDEXEDPOLYGONALFACE((140,142,188)); +#952=IFCINDEXEDPOLYGONALFACE((82,109,131)); +#953=IFCINDEXEDPOLYGONALFACE((109,15,51)); +#954=IFCINDEXEDPOLYGONALFACE((210,151,29)); +#955=IFCINDEXEDPOLYGONALFACE((45,18,30)); +#956=IFCINDEXEDPOLYGONALFACE((204,195,196)); +#957=IFCINDEXEDPOLYGONALFACE((229,230,238)); +#958=IFCINDEXEDPOLYGONALFACE((161,87,217)); +#959=IFCINDEXEDPOLYGONALFACE((305,313,264)); +#960=IFCINDEXEDPOLYGONALFACE((84,76,60)); +#961=IFCINDEXEDPOLYGONALFACE((185,194,190)); +#962=IFCINDEXEDPOLYGONALFACE((5,1,133)); +#963=IFCINDEXEDPOLYGONALFACE((226,46,237)); +#964=IFCINDEXEDPOLYGONALFACE((23,9,277)); +#965=IFCINDEXEDPOLYGONALFACE((76,84,8)); +#966=IFCINDEXEDPOLYGONALFACE((294,267,274)); +#967=IFCINDEXEDPOLYGONALFACE((145,144,114)); +#968=IFCINDEXEDPOLYGONALFACE((41,15,203)); +#969=IFCINDEXEDPOLYGONALFACE((13,11,43)); +#970=IFCINDEXEDPOLYGONALFACE((234,143,125)); +#971=IFCINDEXEDPOLYGONALFACE((40,16,38)); +#972=IFCINDEXEDPOLYGONALFACE((272,41,85)); +#973=IFCINDEXEDPOLYGONALFACE((215,49,48)); +#974=IFCINDEXEDPOLYGONALFACE((39,137,241)); +#975=IFCINDEXEDPOLYGONALFACE((311,274,292)); +#976=IFCINDEXEDPOLYGONALFACE((69,251,86)); +#977=IFCINDEXEDPOLYGONALFACE((310,87,161)); +#978=IFCINDEXEDPOLYGONALFACE((248,256,250)); +#979=IFCINDEXEDPOLYGONALFACE((68,78,17)); +#980=IFCINDEXEDPOLYGONALFACE((156,231,171)); +#981=IFCINDEXEDPOLYGONALFACE((59,27,8)); +#982=IFCINDEXEDPOLYGONALFACE((54,33,371)); +#983=IFCINDEXEDPOLYGONALFACE((237,192,228)); +#984=IFCINDEXEDPOLYGONALFACE((362,363,105)); +#985=IFCINDEXEDPOLYGONALFACE((291,293,314)); +#986=IFCINDEXEDPOLYGONALFACE((45,112,70)); +#987=IFCINDEXEDPOLYGONALFACE((62,40,63)); +#988=IFCINDEXEDPOLYGONALFACE((91,31,19)); +#989=IFCINDEXEDPOLYGONALFACE((269,270,260)); +#990=IFCINDEXEDPOLYGONALFACE((53,3,35)); +#991=IFCINDEXEDPOLYGONALFACE((67,47,187)); +#992=IFCINDEXEDPOLYGONALFACE((135,73,66)); +#993=IFCINDEXEDPOLYGONALFACE((333,329,330)); +#994=IFCINDEXEDPOLYGONALFACE((315,261,254)); +#995=IFCINDEXEDPOLYGONALFACE((127,23,12)); +#996=IFCINDEXEDPOLYGONALFACE((100,121,128)); +#997=IFCINDEXEDPOLYGONALFACE((319,375,5)); +#998=IFCINDEXEDPOLYGONALFACE((374,83,158)); +#999=IFCINDEXEDPOLYGONALFACE((375,139,83)); +#1000=IFCINDEXEDPOLYGONALFACE((314,293,274)); +#1001=IFCPOLYGONALFACESET(#287,.F.,(#288,#289,#290,#291,#292,#293,#294,#295,#296,#297,#298,#299,#300,#301,#302,#303,#304,#305,#306,#307,#308,#309,#310,#311,#312,#313,#314,#315,#316,#317,#318,#319,#320,#321,#322,#323,#324,#325,#326,#327,#328,#329,#330,#331,#332,#333,#334,#335,#336,#337,#338,#339,#340,#341,#342,#343,#344,#345,#346,#347,#348,#349,#350,#351,#352,#353,#354,#355,#356,#357,#358,#359,#360,#361,#362,#363,#364,#365,#366,#367,#368,#369,#370,#371,#372,#373,#374,#375,#376,#377,#378,#379,#380,#381,#382,#383,#384,#385,#386,#387,#388,#389,#390,#391,#392,#393,#394,#395,#396,#397,#398,#399,#400,#401,#402,#403,#404,#405,#406,#407,#408,#409,#410,#411,#412,#413,#414,#415,#416,#417,#418,#419,#420,#421,#422,#423,#424,#425,#426,#427,#428,#429,#430,#431,#432,#433,#434,#435,#436,#437,#438,#439,#440,#441,#442,#443,#444,#445,#446,#447,#448,#449,#450,#451,#452,#453,#454,#455,#456,#457,#458,#459,#460,#461,#462,#463,#464,#465,#466,#467,#468,#469,#470,#471,#472,#473,#474,#475,#476,#477,#478,#479,#480,#481,#482,#483,#484,#485,#486,#487,#488,#489,#490,#491,#492,#493,#494,#495,#496,#497,#498,#499,#500,#501,#502,#503,#504,#505,#506,#507,#508,#509,#510,#511,#512,#513,#514,#515,#516,#517,#518,#519,#520,#521,#522,#523,#524,#525,#526,#527,#528,#529,#530,#531,#532,#533,#534,#535,#536,#537,#538,#539,#540,#541,#542,#543,#544,#545,#546,#547,#548,#549,#550,#551,#552,#553,#554,#555,#556,#557,#558,#559,#560,#561,#562,#563,#564,#565,#566,#567,#568,#569,#570,#571,#572,#573,#574,#575,#576,#577,#578,#579,#580,#581,#582,#583,#584,#585,#586,#587,#588,#589,#590,#591,#592,#593,#594,#595,#596,#597,#598,#599,#600,#601,#602,#603,#604,#605,#606,#607,#608,#609,#610,#611,#612,#613,#614,#615,#616,#617,#618,#619,#620,#621,#622,#623,#624,#625,#626,#627,#628,#629,#630,#631,#632,#633,#634,#635,#636,#637,#638,#639,#640,#641,#642,#643,#644,#645,#646,#647,#648,#649,#650,#651,#652,#653,#654,#655,#656,#657,#658,#659,#660,#661,#662,#663,#664,#665,#666,#667,#668,#669,#670,#671,#672,#673,#674,#675,#676,#677,#678,#679,#680,#681,#682,#683,#684,#685,#686,#687,#688,#689,#690,#691,#692,#693,#694,#695,#696,#697,#698,#699,#700,#701,#702,#703,#704,#705,#706,#707,#708,#709,#710,#711,#712,#713,#714,#715,#716,#717,#718,#719,#720,#721,#722,#723,#724,#725,#726,#727,#728,#729,#730,#731,#732,#733,#734,#735,#736,#737,#738,#739,#740,#741,#742,#743,#744,#745,#746,#747,#748,#749,#750,#751,#752,#753,#754,#755,#756,#757,#758,#759,#760,#761,#762,#763,#764,#765,#766,#767,#768,#769,#770,#771,#772,#773,#774,#775,#776,#777,#778,#779,#780,#781,#782,#783,#784,#785,#786,#787,#788,#789,#790,#791,#792,#793,#794,#795,#796,#797,#798,#799,#800,#801,#802,#803,#804,#805,#806,#807,#808,#809,#810,#811,#812,#813,#814,#815,#816,#817,#818,#819,#820,#821,#822,#823,#824,#825,#826,#827,#828,#829,#830,#831,#832,#833,#834,#835,#836,#837,#838,#839,#840,#841,#842,#843,#844,#845,#846,#847,#848,#849,#850,#851,#852,#853,#854,#855,#856,#857,#858,#859,#860,#861,#862,#863,#864,#865,#866,#867,#868,#869,#870,#871,#872,#873,#874,#875,#876,#877,#878,#879,#880,#881,#882,#883,#884,#885,#886,#887,#888,#889,#890,#891,#892,#893,#894,#895,#896,#897,#898,#899,#900,#901,#902,#903,#904,#905,#906,#907,#908,#909,#910,#911,#912,#913,#914,#915,#916,#917,#918,#919,#920,#921,#922,#923,#924,#925,#926,#927,#928,#929,#930,#931,#932,#933,#934,#935,#936,#937,#938,#939,#940,#941,#942,#943,#944,#945,#946,#947,#948,#949,#950,#951,#952,#953,#954,#955,#956,#957,#958,#959,#960,#961,#962,#963,#964,#965,#966,#967,#968,#969,#970,#971,#972,#973,#974,#975,#976,#977,#978,#979,#980,#981,#982,#983,#984,#985,#986,#987,#988,#989,#990,#991,#992,#993,#994,#995,#996,#997,#998,#999,#1000),$); +#1002=IFCSHAPEREPRESENTATION(#15,'Body','Tessellation',(#1001)); +#1003=IFCREPRESENTATIONMAP(#280,#1002); +#1004=IFCCARTESIANPOINT((0.,0.,0.)); +#1005=IFCDIRECTION((0.,0.,1.)); +#1006=IFCDIRECTION((1.,0.,0.)); +#1007=IFCAXIS2PLACEMENT3D(#1004,#1005,#1006); +#1013=IFCCARTESIANPOINTLIST2D(((-161.386370658875,0.390071421861649),(-162.97847032547,30.6398719549179),(-152.914509177208,57.6198659837246),(-148.716494441032,79.5774236321449),(-149.392008781433,102.066904306412),(-151.44681930542,125.798091292381),(-157.580137252808,132.616892457008),(-169.090509414673,130.419373512268),(-180.844187736511,118.465758860111),(-182.052731513977,90.6300097703934),(-183.831930160522,55.2833341062069),(-183.684945106506,39.6271869540215),(-192.724362015724,-4.67484071850777))); +#1014=IFCINDEXEDPOLYCURVE(#1013,$,$); +#1015=IFCCARTESIANPOINTLIST2D(((-173.348978161812,20.3548446297646),(-163.15957903862,61.7493018507957),(-157.428041100502,97.4122136831284),(-165.070101618767,119.064696133137))); +#1016=IFCINDEXEDPOLYCURVE(#1015,$,$); +#1017=IFCCARTESIANPOINTLIST2D(((-160.456106066704,37.40194439888),(-130.220890045166,47.9081235826015),(-97.7480411529541,54.0151223540306),(-74.405312538147,73.5662579536438),(-37.3027324676514,89.5451977849007),(-5.24431467056274,85.4801684617996),(44.9999570846558,68.9153224229813),(76.8988728523254,42.0413166284561),(100.000023841858,20.0000032782555),(112.531423568726,-13.4119689464569),(110.93932390213,-41.5389761328697),(101.917445659637,-74.4422599673271),(128.680348396301,-63.8554915785789),(138.488471508026,-43.2419404387474),(134.837985038757,-13.5693177580833),(123.972177505493,5.19884377717972),(100.000023841858,20.0000032782555))); +#1018=IFCINDEXEDPOLYCURVE(#1017,$,$); +#1019=IFCCARTESIANPOINTLIST2D(((-41.3289070129395,60.5994611978531),(-55.4808378219604,46.8897596001625),(-78.0355930328369,36.7180481553078),(-99.2635488510132,18.5858532786369),(-136.412382125854,4.43390011787415))); +#1020=IFCINDEXEDPOLYCURVE(#1019,$,$); +#1021=IFCCARTESIANPOINTLIST2D(((-143.91028881073,8.47188383340836),(-127.020835876465,23.3357548713684),(-99.5742082595825,49.370177090168),(-68.5850381851196,68.8069462776184),(-29.6431183815002,76.3391554355621),(-26.7347097396851,71.21342420578),(-33.8107347488403,58.3882182836533),(-58.5765838623047,19.0281048417091),(-103.685975074768,-7.94906169176102),(-130.663156509399,-14.5827829837799))); +#1022=IFCINDEXEDPOLYCURVE(#1021,$,$); +#1023=IFCCARTESIANPOINTLIST2D(((101.917445659637,-74.4422599673271),(77.6327848434448,-98.9715680480003),(43.5214042663574,-123.003117740154),(-1.87504291534424,-136.098772287369),(-44.7412729263306,-130.966305732727),(-75.5681991577148,-105.624251067638),(-114.447318017483,-103.237792849541),(-148.344993591309,-102.713964879513),(-129.387378692627,-83.7726220488548),(-112.089991569519,-52.3208752274513))); +#1024=IFCINDEXEDPOLYCURVE(#1023,$,$); +#1025=IFCCARTESIANPOINTLIST2D(((-148.344993591309,-102.713964879513),(-160.57014465332,-110.72414368391),(-187.541648745537,-117.346309125423),(-205.768346786499,-106.695257127285),(-214.284062385559,-90.5132815241814),(-222.012758255005,-43.5851588845253),(-217.635273933411,-23.6888602375984),(-189.349979162216,11.8629187345505))); +#1026=IFCINDEXEDPOLYCURVE(#1025,$,$); +#1027=IFCGEOMETRICCURVESET((#1014,#1016,#1018,#1020,#1022,#1024,#1026)); +#1028=IFCSHAPEREPRESENTATION(#28,'Body','Annotation2D',(#1027)); +#1029=IFCREPRESENTATIONMAP(#1007,#1028); +#1030=IFCFURNITURETYPE('3Kyc6IyarAUw3_8fkNtXIg',$,'BUN01',$,$,$,(#1003,#1029),$,$,.NOTDEFINED.,.NOTDEFINED.); +#1031=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('setout-point'),$); +#1032=IFCPROPERTYSET('2dQzX_K4r1Xet6dz9zBlgs',$,'EPset_Annotation',$,(#1031)); +#1033=IFCTYPEPRODUCT('0TBMBnD_b66QLUWKD9HLsc',$,'SETOUT-POINT',$,'IfcAnnotation/SYMBOL',(#1032),$,$); +#1034=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('control-point'),$); +#1035=IFCPROPERTYSET('3APQOw$FP1ivxp08UxHsl6',$,'EPset_Annotation',$,(#1034)); +#1036=IFCTYPEPRODUCT('0vo_7PU3H9ygTnrIipqPRI',$,'CONTROL-POINT',$,'IfcAnnotation/SYMBOL',(#1035),$,$); +#1037=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('traverse-point'),$); +#1038=IFCPROPERTYSET('0cTSYXMc9B$PTXVDrhBYYW',$,'EPset_Annotation',$,(#1037)); +#1039=IFCTYPEPRODUCT('32V27G3$T1OO3TbXg6948F',$,'TRAVERSE-POINT',$,'IfcAnnotation/SYMBOL',(#1038),$,$); +#1040=IFCPROPERTYSINGLEVALUE('Classes',$,IFCLABEL('dashed'),$); +#1041=IFCPROPERTYSET('0RiYsOxp529gXnCkw44wF8',$,'EPset_Annotation',$,(#1040)); +#1042=IFCTYPEPRODUCT('2PXIC7Bg914gJhRh2XeGnN',$,'DASHED',$,'IfcAnnotation/LINEWORK',(#1041),$,$); +#1043=IFCPROPERTYSINGLEVALUE('Classes',$,IFCLABEL('fine'),$); +#1044=IFCPROPERTYSET('2gzly9D0L1qPK2MExeXgRO',$,'EPset_Annotation',$,(#1043)); +#1045=IFCTYPEPRODUCT('1Ou1kA3Vb4HhuNBvM0uGe0',$,'FINE',$,'IfcAnnotation/LINEWORK',(#1044),$,$); +#1046=IFCPROPERTYSINGLEVALUE('Classes',$,IFCLABEL('thin'),$); +#1047=IFCPROPERTYSET('0vsVpqs6zArvA2bfBtvQew',$,'EPset_Annotation',$,(#1046)); +#1048=IFCTYPEPRODUCT('3lx$KQPRbEZwbQ7Xdfm5gw',$,'THIN',$,'IfcAnnotation/LINEWORK',(#1047),$,$); +#1049=IFCPROPERTYSINGLEVALUE('Classes',$,IFCLABEL('medium'),$); +#1050=IFCPROPERTYSET('240rEBDOn8PAvfnm_43s55',$,'EPset_Annotation',$,(#1049)); +#1051=IFCTYPEPRODUCT('00j$y97p903w2HOb35lAQ2',$,'MEDIUM',$,'IfcAnnotation/LINEWORK',(#1050),$,$); +#1052=IFCPROPERTYSINGLEVALUE('Classes',$,IFCLABEL('thick'),$); +#1053=IFCPROPERTYSET('2IEGHncr1D$R8fKA9zCEJP',$,'EPset_Annotation',$,(#1052)); +#1054=IFCTYPEPRODUCT('2tVdFGorj6dfrL4uA1kyW8',$,'THICK',$,'IfcAnnotation/LINEWORK',(#1053),$,$); +#1055=IFCPROPERTYSINGLEVALUE('Classes',$,IFCLABEL('strong'),$); +#1056=IFCPROPERTYSET('1gRomAS1L2WguA51ZI0E40',$,'EPset_Annotation',$,(#1055)); +#1057=IFCTYPEPRODUCT('1T5C$$ONTBB8A9a7vv0Gn6',$,'STRONG',$,'IfcAnnotation/LINEWORK',(#1056),$,$); +#1058=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('setout-tag'),$); +#1059=IFCPROPERTYSET('0nF9du8qDAF9aldqjCUsbX',$,'EPset_Annotation',$,(#1058)); +#1060=IFCCARTESIANPOINT((0.,0.,0.)); +#1061=IFCDIRECTION((0.,0.,1.)); +#1062=IFCDIRECTION((1.,0.,0.)); +#1063=IFCAXIS2PLACEMENT3D(#1060,#1061,#1062); +#1069=IFCCARTESIANPOINT((0.,0.,0.)); +#1070=IFCDIRECTION((0.,0.,1.)); +#1071=IFCDIRECTION((1.,0.,0.)); +#1072=IFCAXIS2PLACEMENT3D(#1069,#1070,#1071); +#1073=IFCPLANAREXTENT(1000000.,1000000.); +#1074=IFCTEXTLITERALWITHEXTENT('E ``round({{easting}}, 0.001)``',#1072,.RIGHT.,#1073,'center'); +#1075=IFCCARTESIANPOINT((0.,0.,0.)); +#1076=IFCDIRECTION((0.,0.,1.)); +#1077=IFCDIRECTION((1.,0.,0.)); +#1078=IFCAXIS2PLACEMENT3D(#1075,#1076,#1077); +#1079=IFCPLANAREXTENT(1000000.,1000000.); +#1080=IFCTEXTLITERALWITHEXTENT('N ``round({{northing}}, 0.001)``',#1078,.RIGHT.,#1079,'center'); +#1081=IFCSHAPEREPRESENTATION(#29,'Annotation','Annotation2D',(#1074,#1080)); +#1082=IFCREPRESENTATIONMAP(#1063,#1081); +#1083=IFCTYPEPRODUCT('2LmKYrAEr2K8EwJyCAsyc4',$,'SETOUT-TAG',$,'IfcAnnotation/TEXT',(#1059),(#1082),$); +#1084=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('door-tag'),$); +#1085=IFCPROPERTYSET('1MUPGQolnDEgp0NxcMN56E',$,'EPset_Annotation',$,(#1084)); +#1086=IFCCARTESIANPOINT((0.,0.,0.)); +#1087=IFCDIRECTION((0.,0.,1.)); +#1088=IFCDIRECTION((1.,0.,0.)); +#1089=IFCAXIS2PLACEMENT3D(#1086,#1087,#1088); +#1095=IFCCARTESIANPOINT((0.,0.,0.)); +#1096=IFCDIRECTION((0.,0.,1.)); +#1097=IFCDIRECTION((1.,0.,0.)); +#1098=IFCAXIS2PLACEMENT3D(#1095,#1096,#1097); +#1099=IFCPLANAREXTENT(1000000.,1000000.); +#1100=IFCTEXTLITERALWITHEXTENT('{{type.Name}}',#1098,.RIGHT.,#1099,'center'); +#1101=IFCCARTESIANPOINT((0.,0.,0.)); +#1102=IFCDIRECTION((0.,0.,1.)); +#1103=IFCDIRECTION((1.,0.,0.)); +#1104=IFCAXIS2PLACEMENT3D(#1101,#1102,#1103); +#1105=IFCPLANAREXTENT(1000000.,1000000.); +#1106=IFCTEXTLITERALWITHEXTENT('{{Name}}',#1104,.RIGHT.,#1105,'center'); +#1107=IFCSHAPEREPRESENTATION(#29,'Annotation','Annotation2D',(#1100,#1106)); +#1108=IFCREPRESENTATIONMAP(#1089,#1107); +#1109=IFCTYPEPRODUCT('1cdhVmqEPF6e13_TFOdlQw',$,'DOOR-TAG',$,'IfcAnnotation/TEXT',(#1085),(#1108),$); +#1110=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('window-tag'),$); +#1111=IFCPROPERTYSET('2X7dAo_1P5ruRnlKA4kIl6',$,'EPset_Annotation',$,(#1110)); +#1112=IFCCARTESIANPOINT((0.,0.,0.)); +#1113=IFCDIRECTION((0.,0.,1.)); +#1114=IFCDIRECTION((1.,0.,0.)); +#1115=IFCAXIS2PLACEMENT3D(#1112,#1113,#1114); +#1121=IFCCARTESIANPOINT((0.,0.,0.)); +#1122=IFCDIRECTION((0.,0.,1.)); +#1123=IFCDIRECTION((1.,0.,0.)); +#1124=IFCAXIS2PLACEMENT3D(#1121,#1122,#1123); +#1125=IFCPLANAREXTENT(1000000.,1000000.); +#1126=IFCTEXTLITERALWITHEXTENT('{{Name}}',#1124,.RIGHT.,#1125,'center'); +#1127=IFCSHAPEREPRESENTATION(#29,'Annotation','Annotation2D',(#1126)); +#1128=IFCREPRESENTATIONMAP(#1115,#1127); +#1129=IFCTYPEPRODUCT('0OuDi3gRH2CxW9mrtE0vXw',$,'WINDOW-TAG',$,'IfcAnnotation/TEXT',(#1111),(#1128),$); +#1130=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('space-tag'),$); +#1131=IFCPROPERTYSET('02FAX8dIzAlunWD5ak$6sU',$,'EPset_Annotation',$,(#1130)); +#1132=IFCCARTESIANPOINT((0.,0.,0.)); +#1133=IFCDIRECTION((0.,0.,1.)); +#1134=IFCDIRECTION((1.,0.,0.)); +#1135=IFCAXIS2PLACEMENT3D(#1132,#1133,#1134); +#1141=IFCCARTESIANPOINT((0.,0.,0.)); +#1142=IFCDIRECTION((0.,0.,1.)); +#1143=IFCDIRECTION((1.,0.,0.)); +#1144=IFCAXIS2PLACEMENT3D(#1141,#1142,#1143); +#1145=IFCPLANAREXTENT(1000000.,1000000.); +#1146=IFCTEXTLITERALWITHEXTENT('{{Name}}',#1144,.RIGHT.,#1145,'center'); +#1147=IFCCARTESIANPOINT((0.,0.,0.)); +#1148=IFCDIRECTION((0.,0.,1.)); +#1149=IFCDIRECTION((1.,0.,0.)); +#1150=IFCAXIS2PLACEMENT3D(#1147,#1148,#1149); +#1151=IFCPLANAREXTENT(1000000.,1000000.); +#1152=IFCTEXTLITERALWITHEXTENT('{{Description}}',#1150,.RIGHT.,#1151,'center'); +#1153=IFCCARTESIANPOINT((0.,0.,0.)); +#1154=IFCDIRECTION((0.,0.,1.)); +#1155=IFCDIRECTION((1.,0.,0.)); +#1156=IFCAXIS2PLACEMENT3D(#1153,#1154,#1155); +#1157=IFCPLANAREXTENT(1000000.,1000000.); +#1158=IFCTEXTLITERALWITHEXTENT('``round({{Qto_SpaceBaseQuantities.NetFloorArea}}, 0.01)``',#1156,.RIGHT.,#1157,'center'); +#1159=IFCSHAPEREPRESENTATION(#29,'Annotation','Annotation2D',(#1146,#1152,#1158)); +#1160=IFCREPRESENTATIONMAP(#1135,#1159); +#1161=IFCTYPEPRODUCT('3WEV_9wQn6AQTESgjW36PH',$,'SPACE-TAG',$,'IfcAnnotation/TEXT',(#1131),(#1160),$); +#1162=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('rectangle-tag'),$); +#1163=IFCPROPERTYSET('2KPJlGyer0UBmphfR7952k',$,'EPset_Annotation',$,(#1162)); +#1164=IFCCARTESIANPOINT((0.,0.,0.)); +#1165=IFCDIRECTION((0.,0.,1.)); +#1166=IFCDIRECTION((1.,0.,0.)); +#1167=IFCAXIS2PLACEMENT3D(#1164,#1165,#1166); +#1173=IFCCARTESIANPOINT((0.,0.,0.)); +#1174=IFCDIRECTION((0.,0.,1.)); +#1175=IFCDIRECTION((1.,0.,0.)); +#1176=IFCAXIS2PLACEMENT3D(#1173,#1174,#1175); +#1177=IFCPLANAREXTENT(1000000.,1000000.); +#1178=IFCTEXTLITERALWITHEXTENT('{{material.Name}}',#1176,.RIGHT.,#1177,'center'); +#1179=IFCSHAPEREPRESENTATION(#29,'Annotation','Annotation2D',(#1178)); +#1180=IFCREPRESENTATIONMAP(#1167,#1179); +#1181=IFCTYPEPRODUCT('1evqvQLLL1zxdsIWqEn0lK',$,'MATERIAL-TAG',$,'IfcAnnotation/TEXT',(#1163),(#1180),$); +#1182=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('capsule-tag'),$); +#1183=IFCPROPERTYSET('15bspgnA9CEuFox6xFjoTL',$,'EPset_Annotation',$,(#1182)); +#1184=IFCCARTESIANPOINT((0.,0.,0.)); +#1185=IFCDIRECTION((0.,0.,1.)); +#1186=IFCDIRECTION((1.,0.,0.)); +#1187=IFCAXIS2PLACEMENT3D(#1184,#1185,#1186); +#1193=IFCCARTESIANPOINT((0.,0.,0.)); +#1194=IFCDIRECTION((0.,0.,1.)); +#1195=IFCDIRECTION((1.,0.,0.)); +#1196=IFCAXIS2PLACEMENT3D(#1193,#1194,#1195); +#1197=IFCPLANAREXTENT(1000000.,1000000.); +#1198=IFCTEXTLITERALWITHEXTENT('{{type.Name}}',#1196,.RIGHT.,#1197,'center'); +#1199=IFCSHAPEREPRESENTATION(#29,'Annotation','Annotation2D',(#1198)); +#1200=IFCREPRESENTATIONMAP(#1187,#1199); +#1201=IFCTYPEPRODUCT('3Nem6d4xX87O3deyWDi3AW',$,'TYPE-TAG',$,'IfcAnnotation/TEXT',(#1183),(#1200),$); +#1202=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('capsule-tag'),$); +#1203=IFCPROPERTYSET('1d53tifbv2rwcoFFJosiHf',$,'EPset_Annotation',$,(#1202)); +#1204=IFCCARTESIANPOINT((0.,0.,0.)); +#1205=IFCDIRECTION((0.,0.,1.)); +#1206=IFCDIRECTION((1.,0.,0.)); +#1207=IFCAXIS2PLACEMENT3D(#1204,#1205,#1206); +#1213=IFCCARTESIANPOINT((0.,0.,0.)); +#1214=IFCDIRECTION((0.,0.,1.)); +#1215=IFCDIRECTION((1.,0.,0.)); +#1216=IFCAXIS2PLACEMENT3D(#1213,#1214,#1215); +#1217=IFCPLANAREXTENT(1000000.,1000000.); +#1218=IFCTEXTLITERALWITHEXTENT('{{Name}}',#1216,.RIGHT.,#1217,'center'); +#1219=IFCSHAPEREPRESENTATION(#29,'Annotation','Annotation2D',(#1218)); +#1220=IFCREPRESENTATIONMAP(#1207,#1219); +#1221=IFCTYPEPRODUCT('0klFX9AjnEnPBkdIURv8XD',$,'NAME-TAG',$,'IfcAnnotation/TEXT',(#1203),(#1220),$); +#1222=IFCWALL('3fphKxC81BMwRc46o$1Cqj',$,'Wall_02',$,$,#1336,#1230,$,$); +#1223=IFCRELCONTAINEDINSPATIALSTRUCTURE('2EB4R7ETPDiw0Rbe6drZly',$,$,$,(#1513,#1486,#1320,#1545),#42); +#1224=IFCRELDEFINESBYTYPE('0jBmsPz3v5HvFHMoyoj824',$,$,$,(#1222,#1249),#71); +#1225=IFCMATERIALLAYERSETUSAGE(#74,.AXIS2.,.POSITIVE.,0.,$); +#1226=IFCRELASSOCIATESMATERIAL('0q0BFzauPCjBOVG64mRZDH',$,$,$,(#1222),#1225); +#1230=IFCPRODUCTDEFINITIONSHAPE($,$,(#1485,#1482)); +#1246=IFCPROPERTYSET('2Tu$MYW3P1TBBc43ffoRi_',$,'EPset_Parametric',$,(#1248)); +#1247=IFCRELDEFINESBYPROPERTIES('2hAhcaDLj0F9gCnfJAmLlK',$,$,$,(#1222),#1246); +#1248=IFCPROPERTYSINGLEVALUE('Engine',$,IFCLABEL('Bonsai.DumbLayer2'),$); +#1249=IFCWALL('3rSTXfFcn4u9mBNbgk9MSB',$,'Wall_01',$,$,#1382,#1255,$,$); +#1250=IFCMATERIALLAYERSETUSAGE(#74,.AXIS2.,.POSITIVE.,0.,$); +#1251=IFCRELASSOCIATESMATERIAL('2l$04b$nXEux0KWqMJPg6c',$,$,$,(#1249),#1250); +#1255=IFCPRODUCTDEFINITIONSHAPE($,$,(#1469,#1466)); +#1271=IFCPROPERTYSET('2_ccgyBVv31Rbk8e0gjsdb',$,'EPset_Parametric',$,(#1273)); +#1272=IFCRELDEFINESBYPROPERTIES('10j1y8fbb4welJJcuVWQJM',$,$,$,(#1249),#1271); +#1273=IFCPROPERTYSINGLEVALUE('Engine',$,IFCLABEL('Bonsai.DumbLayer2'),$); +#1274=IFCRELCONNECTSPATHELEMENTS('0vDxCGayX2zfucYRconjsZ',$,$,'MITRE',$,#1249,#1222,(),(),.ATSTART.,.ATSTART.); +#1320=IFCELEMENTASSEMBLY('33Tq9eGfD3GxapogLdNo3a',$,'Assembly',$,$,#1330,$,$,$,$); +#1326=IFCCARTESIANPOINT((0.,0.,0.)); +#1327=IFCDIRECTION((0.,0.,1.)); +#1328=IFCDIRECTION((1.,0.,0.)); +#1329=IFCAXIS2PLACEMENT3D(#1326,#1327,#1328); +#1330=IFCLOCALPLACEMENT(#65,#1329); +#1331=IFCRELAGGREGATES('3KnbkZNYXBafjneBxz3j6B',$,$,$,#1320,(#1249,#1222)); +#1332=IFCCARTESIANPOINT((0.,0.,0.)); +#1333=IFCDIRECTION((0.,0.,1.)); +#1334=IFCDIRECTION((1.,0.,0.)); +#1335=IFCAXIS2PLACEMENT3D(#1332,#1333,#1334); +#1336=IFCLOCALPLACEMENT(#1330,#1335); +#1378=IFCCARTESIANPOINT((1.39858280630235E-12,0.,0.)); +#1379=IFCDIRECTION((0.,0.,1.)); +#1380=IFCDIRECTION((1.94707183709394E-07,-0.999999999999981,0.)); +#1381=IFCAXIS2PLACEMENT3D(#1378,#1379,#1380); +#1382=IFCLOCALPLACEMENT(#1330,#1381); +#1457=IFCCARTESIANPOINTLIST2D(((-100.000000000002,0.),(1.94707183709398E-05,100.),(5000.,100.),(5000.,0.))); +#1458=IFCINDEXEDPOLYCURVE(#1457,(IFCLINEINDEX((1,2,3,4,1))),$); +#1459=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#1458); +#1460=IFCCARTESIANPOINT((0.,0.,0.)); +#1461=IFCDIRECTION((0.,0.,1.)); +#1462=IFCDIRECTION((1.,0.,0.)); +#1463=IFCAXIS2PLACEMENT3D(#1460,#1461,#1462); +#1464=IFCDIRECTION((0.,0.,1.)); +#1465=IFCEXTRUDEDAREASOLID(#1459,#1463,#1464,3000.); +#1466=IFCSHAPEREPRESENTATION(#15,'Body','SweptSolid',(#1465)); +#1467=IFCCARTESIANPOINTLIST2D(((0.,0.),(5000.,-0.000119209276817855))); +#1468=IFCINDEXEDPOLYCURVE(#1467,$,$); +#1469=IFCSHAPEREPRESENTATION(#27,'Axis','Curve2D',(#1468)); +#1473=IFCCARTESIANPOINTLIST2D(((100.000000000001,0.),(-1.70865328345826E-05,100.),(5000.,100.),(5000.,0.))); +#1474=IFCINDEXEDPOLYCURVE(#1473,(IFCLINEINDEX((1,2,3,4,1))),$); +#1475=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#1474); +#1476=IFCCARTESIANPOINT((0.,0.,0.)); +#1477=IFCDIRECTION((0.,0.,1.)); +#1478=IFCDIRECTION((1.,0.,0.)); +#1479=IFCAXIS2PLACEMENT3D(#1476,#1477,#1478); +#1480=IFCDIRECTION((0.,0.,1.)); +#1481=IFCEXTRUDEDAREASOLID(#1475,#1479,#1480,3000.00023841858); +#1482=IFCSHAPEREPRESENTATION(#15,'Body','SweptSolid',(#1481)); +#1483=IFCCARTESIANPOINTLIST2D(((0.,0.),(5000.,0.))); +#1484=IFCINDEXEDPOLYCURVE(#1483,$,$); +#1485=IFCSHAPEREPRESENTATION(#27,'Axis','Curve2D',(#1484)); +#1486=IFCFURNITURE('1Sb706bhrENfbE9hKU48_S',$,'Furniture',$,$,#1512,#1495,$,$); +#1487=IFCRELDEFINESBYTYPE('0Ij9V31nz4fh$peML8rHbD',$,$,$,(#1486),#1030); +#1488=IFCCARTESIANPOINT((0.,0.,0.)); +#1489=IFCDIRECTION((1.,0.,0.)); +#1490=IFCDIRECTION((0.,1.,0.)); +#1491=IFCDIRECTION((0.,0.,1.)); +#1492=IFCCARTESIANTRANSFORMATIONOPERATOR3D(#1489,#1490,#1488,1.,#1491); +#1493=IFCMAPPEDITEM(#1003,#1492); +#1494=IFCSHAPEREPRESENTATION(#15,'Body','MappedRepresentation',(#1493)); +#1495=IFCPRODUCTDEFINITIONSHAPE($,$,(#1494,#1502)); +#1496=IFCCARTESIANPOINT((0.,0.,0.)); +#1497=IFCDIRECTION((1.,0.,0.)); +#1498=IFCDIRECTION((0.,1.,0.)); +#1499=IFCDIRECTION((0.,0.,1.)); +#1500=IFCCARTESIANTRANSFORMATIONOPERATOR3D(#1497,#1498,#1496,1.,#1499); +#1501=IFCMAPPEDITEM(#1029,#1500); +#1502=IFCSHAPEREPRESENTATION(#28,'Body','MappedRepresentation',(#1501)); +#1508=IFCCARTESIANPOINT((3652.57239341736,3233.63018035889,7.45058059692383E-06)); +#1509=IFCDIRECTION((0.,0.,1.)); +#1510=IFCDIRECTION((1.,0.,0.)); +#1511=IFCAXIS2PLACEMENT3D(#1508,#1509,#1510); +#1512=IFCLOCALPLACEMENT(#65,#1511); +#1513=IFCSLAB('0VbnYWxhj2CvlANx9odXFl',$,'Slab',$,$,#1521,#1528,$,$); +#1514=IFCRELDEFINESBYTYPE('2gPl7v_sfDchsCMRc9DNak',$,$,$,(#1513),#108); +#1515=IFCMATERIALLAYERSETUSAGE(#111,.AXIS3.,.POSITIVE.,-200.,$); +#1516=IFCRELASSOCIATESMATERIAL('3e$Ju5XVL2YhZkvCqEpR4x',$,$,$,(#1513),#1515); +#1517=IFCCARTESIANPOINT((-3.13916466154751E-05,100.000001490116,0.)); +#1518=IFCDIRECTION((0.,0.,1.)); +#1519=IFCDIRECTION((1.,0.,0.)); +#1520=IFCAXIS2PLACEMENT3D(#1517,#1518,#1519); +#1521=IFCLOCALPLACEMENT(#65,#1520); +#1522=IFCCARTESIANPOINTLIST2D(((0.,0.),(0.00754979009798262,-100000.),(100000.007629395,-99999.9847412109),(99999.9847412109,0.0137314200401306),(0.,0.))); +#1523=IFCINDEXEDPOLYCURVE(#1522,$,$); +#1524=IFCDIRECTION((0.,0.,1.)); +#1525=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#1523); +#1526=IFCEXTRUDEDAREASOLID(#1525,#1535,#1524,200.); +#1527=IFCSHAPEREPRESENTATION(#15,'Body','SweptSolid',(#1526)); +#1528=IFCPRODUCTDEFINITIONSHAPE($,$,(#1527)); +#1529=IFCPROPERTYSET('28pnRt0NXCjOi1lCnPN6KX',$,'EPset_Parametric',$,(#1531)); +#1530=IFCRELDEFINESBYPROPERTIES('0nMlPeDT5D$OSy1tx9_6Ob',$,$,$,(#1513),#1529); +#1531=IFCPROPERTYSINGLEVALUE('Engine',$,IFCLABEL('Bonsai.DumbLayer3'),$); +#1532=IFCCARTESIANPOINT((-0.,-0.,-200.)); +#1533=IFCDIRECTION((0.,0.,1.)); +#1534=IFCDIRECTION((1.,0.,0.)); +#1535=IFCAXIS2PLACEMENT3D(#1532,#1533,#1534); +#1536=IFCCARTESIANPOINTLIST3D(((0.,0.,0.),(0.,0.,1999.99987792969),(0.,1999.99987792969,0.),(0.,1999.99987792969,1999.99987792969),(1999.99987792969,0.,0.),(1999.99987792969,0.,1999.99987792969),(1999.99987792969,1999.99987792969,0.),(1999.99987792969,1999.99987792969,1999.99987792969))); +#1537=IFCINDEXEDPOLYGONALFACE((1,2,4,3)); +#1538=IFCINDEXEDPOLYGONALFACE((3,4,8,7)); +#1539=IFCINDEXEDPOLYGONALFACE((7,8,6,5)); +#1540=IFCINDEXEDPOLYGONALFACE((5,6,2,1)); +#1541=IFCINDEXEDPOLYGONALFACE((3,7,5,1)); +#1542=IFCINDEXEDPOLYGONALFACE((8,4,2,6)); +#1543=IFCPOLYGONALFACESET(#1536,$,(#1537,#1538,#1539,#1540,#1541,#1542),$); +#1544=IFCSHAPEREPRESENTATION(#15,'Body','Tessellation',(#1543)); +#1545=IFCBUILDINGELEMENTPROXY('1vHaLvW0jCZfGnicRbz_9o',$,'Cube',$,$,#1551,#1546,$,.COMPLEX.); +#1546=IFCPRODUCTDEFINITIONSHAPE($,$,(#1544)); +#1547=IFCCARTESIANPOINT((1000000.06103516,1000000.06103516,0.)); +#1548=IFCDIRECTION((0.,0.,1.)); +#1549=IFCDIRECTION((1.,0.,0.)); +#1550=IFCAXIS2PLACEMENT3D(#1547,#1548,#1549); +#1551=IFCLOCALPLACEMENT(#65,#1550); +ENDSEC; +END-ISO-10303-21; diff --git a/src/bonsai/test/files/wall.ifc b/src/bonsai/test/files/wall.ifc new file mode 100644 index 0000000000..8deb1b23a0 --- /dev/null +++ b/src/bonsai/test/files/wall.ifc @@ -0,0 +1,1141 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition[DesignTransferView]'),'2;1'); +FILE_NAME('wall.ifc','2026-04-28T13:43:46-03:00',(''),(''),'IfcOpenShell 0.0.0','Bonsai 0.8.6-alpha260415-29fe41e','Nobody'); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCPROJECT('2mlx$RowLAlexGZc1k81wn',$,'My Project',$,$,$,$,(#10,#22),#5); +#2=IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.); +#3=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.); +#4=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.); +#5=IFCUNITASSIGNMENT((#3,#4,#2)); +#6=IFCCARTESIANPOINT((0.,0.,0.)); +#7=IFCDIRECTION((0.,0.,1.)); +#8=IFCDIRECTION((1.,0.,0.)); +#9=IFCAXIS2PLACEMENT3D(#6,#7,#8); +#10=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#9,$); +#11=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#10,$,.MODEL_VIEW.,$); +#12=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Axis','Model',*,*,*,*,#10,$,.GRAPH_VIEW.,$); +#13=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Box','Model',*,*,*,*,#10,$,.MODEL_VIEW.,$); +#14=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#10,$,.SECTION_VIEW.,$); +#15=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#10,$,.ELEVATION_VIEW.,$); +#16=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#10,$,.MODEL_VIEW.,$); +#17=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#10,$,.PLAN_VIEW.,$); +#18=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Profile','Model',*,*,*,*,#10,$,.ELEVATION_VIEW.,$); +#19=IFCCARTESIANPOINT((0.,0.)); +#20=IFCDIRECTION((1.,0.)); +#21=IFCAXIS2PLACEMENT2D(#19,#20); +#22=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Plan',2,1.E-05,#21,$); +#23=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Axis','Plan',*,*,*,*,#22,$,.GRAPH_VIEW.,$); +#24=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Plan',*,*,*,*,#22,$,.PLAN_VIEW.,$); +#25=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Plan',*,*,*,*,#22,$,.PLAN_VIEW.,$); +#26=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Plan',*,*,*,*,#22,$,.REFLECTED_PLAN_VIEW.,$); +#27=IFCSITE('3YYNP4k15BvRDtut4BImW8',$,'My Site',$,$,#50,$,$,$,$,$,$,$,$); +#33=IFCBUILDING('1_D6$vuJ59HxZaIM$3MXje',$,'My Building',$,$,#56,$,$,$,$,$,$); +#39=IFCBUILDINGSTOREY('3O7OiaeRP4qeCDCmLlk$$S',$,'My Storey',$,$,#62,$,$,$,$); +#45=IFCRELAGGREGATES('1IV3YPHJb4z86yS0WtE5Bx',$,$,$,#1,(#27)); +#46=IFCCARTESIANPOINT((0.,0.,0.)); +#47=IFCDIRECTION((0.,0.,1.)); +#48=IFCDIRECTION((1.,0.,0.)); +#49=IFCAXIS2PLACEMENT3D(#46,#47,#48); +#50=IFCLOCALPLACEMENT($,#49); +#51=IFCRELAGGREGATES('31qCrDZ8PAKQWwQgM8loQ5',$,$,$,#27,(#33)); +#52=IFCCARTESIANPOINT((0.,0.,0.)); +#53=IFCDIRECTION((0.,0.,1.)); +#54=IFCDIRECTION((1.,0.,0.)); +#55=IFCAXIS2PLACEMENT3D(#52,#53,#54); +#56=IFCLOCALPLACEMENT(#50,#55); +#57=IFCRELAGGREGATES('1cHc7TlX18mw1IF7G4Cndd',$,$,$,#33,(#39)); +#58=IFCCARTESIANPOINT((0.,0.,0.)); +#59=IFCDIRECTION((0.,0.,1.)); +#60=IFCDIRECTION((1.,0.,0.)); +#61=IFCAXIS2PLACEMENT3D(#58,#59,#60); +#62=IFCLOCALPLACEMENT(#56,#61); +#63=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('setout-point'),$); +#64=IFCPROPERTYSET('27lmSbeAXC08EWkEq8XdUG',$,'EPset_Annotation',$,(#63)); +#65=IFCTYPEPRODUCT('0UrP0fLdD5OwzwD41aRKao',$,'SETOUT-POINT',$,'IfcAnnotation/SYMBOL',(#64),$,$); +#66=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('control-point'),$); +#67=IFCPROPERTYSET('23aN9DsdjFcfWq8KGSIVoN',$,'EPset_Annotation',$,(#66)); +#68=IFCTYPEPRODUCT('3IQrQOSFP0WfkuLk0ak7_0',$,'CONTROL-POINT',$,'IfcAnnotation/SYMBOL',(#67),$,$); +#69=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('traverse-point'),$); +#70=IFCPROPERTYSET('1j7aFM1Rb9BRuKgUEn1U10',$,'EPset_Annotation',$,(#69)); +#71=IFCTYPEPRODUCT('3wvbaiaIz8agQgDRzt01vz',$,'TRAVERSE-POINT',$,'IfcAnnotation/SYMBOL',(#70),$,$); +#72=IFCPROPERTYSINGLEVALUE('Classes',$,IFCLABEL('dashed'),$); +#73=IFCPROPERTYSET('27smSHyiv39vhBRSwfDVCD',$,'EPset_Annotation',$,(#72)); +#74=IFCTYPEPRODUCT('0p5ZTfTnX9ZOywroa7Ffql',$,'DASHED',$,'IfcAnnotation/LINEWORK',(#73),$,$); +#75=IFCPROPERTYSINGLEVALUE('Classes',$,IFCLABEL('fine'),$); +#76=IFCPROPERTYSET('0gVTuDcZ5ByRdR3sZnEZUR',$,'EPset_Annotation',$,(#75)); +#77=IFCTYPEPRODUCT('2EvrG9Vuf6t9etgMeWFuJ2',$,'FINE',$,'IfcAnnotation/LINEWORK',(#76),$,$); +#78=IFCPROPERTYSINGLEVALUE('Classes',$,IFCLABEL('thin'),$); +#79=IFCPROPERTYSET('0t6c$uCeT9092GYkbm8hDS',$,'EPset_Annotation',$,(#78)); +#80=IFCTYPEPRODUCT('0IeM1ywXn1qhR_6NhB6N4s',$,'THIN',$,'IfcAnnotation/LINEWORK',(#79),$,$); +#81=IFCPROPERTYSINGLEVALUE('Classes',$,IFCLABEL('medium'),$); +#82=IFCPROPERTYSET('2uTJ11lF98GeN8pPIf340N',$,'EPset_Annotation',$,(#81)); +#83=IFCTYPEPRODUCT('1jZVbCwrTCGhZdKbH4uqTP',$,'MEDIUM',$,'IfcAnnotation/LINEWORK',(#82),$,$); +#84=IFCPROPERTYSINGLEVALUE('Classes',$,IFCLABEL('thick'),$); +#85=IFCPROPERTYSET('2XQxgO16v9vxjvuIJpaPpG',$,'EPset_Annotation',$,(#84)); +#86=IFCTYPEPRODUCT('38zW9E1uH2ae$zat9KrieS',$,'THICK',$,'IfcAnnotation/LINEWORK',(#85),$,$); +#87=IFCPROPERTYSINGLEVALUE('Classes',$,IFCLABEL('strong'),$); +#88=IFCPROPERTYSET('0TcB8Gal96vebTrLWa5CEw',$,'EPset_Annotation',$,(#87)); +#89=IFCTYPEPRODUCT('3G2s7ZLzfDJh5J$2iIzHw9',$,'STRONG',$,'IfcAnnotation/LINEWORK',(#88),$,$); +#90=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('setout-tag'),$); +#91=IFCPROPERTYSET('39$oNFI052cBhtpZCVVLfj',$,'EPset_Annotation',$,(#90)); +#92=IFCCARTESIANPOINT((0.,0.,0.)); +#93=IFCDIRECTION((0.,0.,1.)); +#94=IFCDIRECTION((1.,0.,0.)); +#95=IFCAXIS2PLACEMENT3D(#92,#93,#94); +#101=IFCCARTESIANPOINT((0.,0.,0.)); +#102=IFCDIRECTION((0.,0.,1.)); +#103=IFCDIRECTION((1.,0.,0.)); +#104=IFCAXIS2PLACEMENT3D(#101,#102,#103); +#105=IFCPLANAREXTENT(1000000.,1000000.); +#106=IFCTEXTLITERALWITHEXTENT('E ``round({{easting}}, 0.001)``',#104,.RIGHT.,#105,'center'); +#107=IFCCARTESIANPOINT((0.,0.,0.)); +#108=IFCDIRECTION((0.,0.,1.)); +#109=IFCDIRECTION((1.,0.,0.)); +#110=IFCAXIS2PLACEMENT3D(#107,#108,#109); +#111=IFCPLANAREXTENT(1000000.,1000000.); +#112=IFCTEXTLITERALWITHEXTENT('N ``round({{northing}}, 0.001)``',#110,.RIGHT.,#111,'center'); +#113=IFCSHAPEREPRESENTATION(#16,'Annotation','Annotation2D',(#106,#112)); +#114=IFCREPRESENTATIONMAP(#95,#113); +#115=IFCTYPEPRODUCT('3pq2B$0k52ZOv2bFuRYkxO',$,'SETOUT-TAG',$,'IfcAnnotation/TEXT',(#91),(#114),$); +#116=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('door-tag'),$); +#117=IFCPROPERTYSET('3xUNoLKPT9kB4LMdoctm5k',$,'EPset_Annotation',$,(#116)); +#118=IFCCARTESIANPOINT((0.,0.,0.)); +#119=IFCDIRECTION((0.,0.,1.)); +#120=IFCDIRECTION((1.,0.,0.)); +#121=IFCAXIS2PLACEMENT3D(#118,#119,#120); +#127=IFCCARTESIANPOINT((0.,0.,0.)); +#128=IFCDIRECTION((0.,0.,1.)); +#129=IFCDIRECTION((1.,0.,0.)); +#130=IFCAXIS2PLACEMENT3D(#127,#128,#129); +#131=IFCPLANAREXTENT(1000000.,1000000.); +#132=IFCTEXTLITERALWITHEXTENT('{{type.Name}}',#130,.RIGHT.,#131,'center'); +#133=IFCCARTESIANPOINT((0.,0.,0.)); +#134=IFCDIRECTION((0.,0.,1.)); +#135=IFCDIRECTION((1.,0.,0.)); +#136=IFCAXIS2PLACEMENT3D(#133,#134,#135); +#137=IFCPLANAREXTENT(1000000.,1000000.); +#138=IFCTEXTLITERALWITHEXTENT('{{Name}}',#136,.RIGHT.,#137,'center'); +#139=IFCSHAPEREPRESENTATION(#16,'Annotation','Annotation2D',(#132,#138)); +#140=IFCREPRESENTATIONMAP(#121,#139); +#141=IFCTYPEPRODUCT('11M3ahhrr9NBdB29YEazzw',$,'DOOR-TAG',$,'IfcAnnotation/TEXT',(#117),(#140),$); +#142=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('window-tag'),$); +#143=IFCPROPERTYSET('2MqzSGkXDBcPSolaAQvYO4',$,'EPset_Annotation',$,(#142)); +#144=IFCCARTESIANPOINT((0.,0.,0.)); +#145=IFCDIRECTION((0.,0.,1.)); +#146=IFCDIRECTION((1.,0.,0.)); +#147=IFCAXIS2PLACEMENT3D(#144,#145,#146); +#153=IFCCARTESIANPOINT((0.,0.,0.)); +#154=IFCDIRECTION((0.,0.,1.)); +#155=IFCDIRECTION((1.,0.,0.)); +#156=IFCAXIS2PLACEMENT3D(#153,#154,#155); +#157=IFCPLANAREXTENT(1000000.,1000000.); +#158=IFCTEXTLITERALWITHEXTENT('{{Name}}',#156,.RIGHT.,#157,'center'); +#159=IFCSHAPEREPRESENTATION(#16,'Annotation','Annotation2D',(#158)); +#160=IFCREPRESENTATIONMAP(#147,#159); +#161=IFCTYPEPRODUCT('1eE8Y$BVDFDgG8Fj6d9wiV',$,'WINDOW-TAG',$,'IfcAnnotation/TEXT',(#143),(#160),$); +#162=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('space-tag'),$); +#163=IFCPROPERTYSET('2zKuFdTPj3DhNdQ0U2kd5u',$,'EPset_Annotation',$,(#162)); +#164=IFCCARTESIANPOINT((0.,0.,0.)); +#165=IFCDIRECTION((0.,0.,1.)); +#166=IFCDIRECTION((1.,0.,0.)); +#167=IFCAXIS2PLACEMENT3D(#164,#165,#166); +#173=IFCCARTESIANPOINT((0.,0.,0.)); +#174=IFCDIRECTION((0.,0.,1.)); +#175=IFCDIRECTION((1.,0.,0.)); +#176=IFCAXIS2PLACEMENT3D(#173,#174,#175); +#177=IFCPLANAREXTENT(1000000.,1000000.); +#178=IFCTEXTLITERALWITHEXTENT('{{Name}}',#176,.RIGHT.,#177,'center'); +#179=IFCCARTESIANPOINT((0.,0.,0.)); +#180=IFCDIRECTION((0.,0.,1.)); +#181=IFCDIRECTION((1.,0.,0.)); +#182=IFCAXIS2PLACEMENT3D(#179,#180,#181); +#183=IFCPLANAREXTENT(1000000.,1000000.); +#184=IFCTEXTLITERALWITHEXTENT('{{Description}}',#182,.RIGHT.,#183,'center'); +#185=IFCCARTESIANPOINT((0.,0.,0.)); +#186=IFCDIRECTION((0.,0.,1.)); +#187=IFCDIRECTION((1.,0.,0.)); +#188=IFCAXIS2PLACEMENT3D(#185,#186,#187); +#189=IFCPLANAREXTENT(1000000.,1000000.); +#190=IFCTEXTLITERALWITHEXTENT('``round({{Qto_SpaceBaseQuantities.NetFloorArea}}, 0.01)``',#188,.RIGHT.,#189,'center'); +#191=IFCSHAPEREPRESENTATION(#16,'Annotation','Annotation2D',(#178,#184,#190)); +#192=IFCREPRESENTATIONMAP(#167,#191); +#193=IFCTYPEPRODUCT('0vvfHSiaPBxewA$32C4LdW',$,'SPACE-TAG',$,'IfcAnnotation/TEXT',(#163),(#192),$); +#194=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('rectangle-tag'),$); +#195=IFCPROPERTYSET('1x86VNVk1Drwe5XOj$GZX3',$,'EPset_Annotation',$,(#194)); +#196=IFCCARTESIANPOINT((0.,0.,0.)); +#197=IFCDIRECTION((0.,0.,1.)); +#198=IFCDIRECTION((1.,0.,0.)); +#199=IFCAXIS2PLACEMENT3D(#196,#197,#198); +#205=IFCCARTESIANPOINT((0.,0.,0.)); +#206=IFCDIRECTION((0.,0.,1.)); +#207=IFCDIRECTION((1.,0.,0.)); +#208=IFCAXIS2PLACEMENT3D(#205,#206,#207); +#209=IFCPLANAREXTENT(1000000.,1000000.); +#210=IFCTEXTLITERALWITHEXTENT('{{material.Name}}',#208,.RIGHT.,#209,'center'); +#211=IFCSHAPEREPRESENTATION(#16,'Annotation','Annotation2D',(#210)); +#212=IFCREPRESENTATIONMAP(#199,#211); +#213=IFCTYPEPRODUCT('36lTcd9yT8UBNDejQUNWrq',$,'MATERIAL-TAG',$,'IfcAnnotation/TEXT',(#195),(#212),$); +#214=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('capsule-tag'),$); +#215=IFCPROPERTYSET('3BeqEbDzX0xORamqqSx_kZ',$,'EPset_Annotation',$,(#214)); +#216=IFCCARTESIANPOINT((0.,0.,0.)); +#217=IFCDIRECTION((0.,0.,1.)); +#218=IFCDIRECTION((1.,0.,0.)); +#219=IFCAXIS2PLACEMENT3D(#216,#217,#218); +#225=IFCCARTESIANPOINT((0.,0.,0.)); +#226=IFCDIRECTION((0.,0.,1.)); +#227=IFCDIRECTION((1.,0.,0.)); +#228=IFCAXIS2PLACEMENT3D(#225,#226,#227); +#229=IFCPLANAREXTENT(1000000.,1000000.); +#230=IFCTEXTLITERALWITHEXTENT('{{type.Name}}',#228,.RIGHT.,#229,'center'); +#231=IFCSHAPEREPRESENTATION(#16,'Annotation','Annotation2D',(#230)); +#232=IFCREPRESENTATIONMAP(#219,#231); +#233=IFCTYPEPRODUCT('1rxhEX6I16oPqglXaGZ7Sw',$,'TYPE-TAG',$,'IfcAnnotation/TEXT',(#215),(#232),$); +#234=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('capsule-tag'),$); +#235=IFCPROPERTYSET('11L$PtdnX8LfYigzRZ$g0a',$,'EPset_Annotation',$,(#234)); +#236=IFCCARTESIANPOINT((0.,0.,0.)); +#237=IFCDIRECTION((0.,0.,1.)); +#238=IFCDIRECTION((1.,0.,0.)); +#239=IFCAXIS2PLACEMENT3D(#236,#237,#238); +#245=IFCCARTESIANPOINT((0.,0.,0.)); +#246=IFCDIRECTION((0.,0.,1.)); +#247=IFCDIRECTION((1.,0.,0.)); +#248=IFCAXIS2PLACEMENT3D(#245,#246,#247); +#249=IFCPLANAREXTENT(1000000.,1000000.); +#250=IFCTEXTLITERALWITHEXTENT('{{Name}}',#248,.RIGHT.,#249,'center'); +#251=IFCSHAPEREPRESENTATION(#16,'Annotation','Annotation2D',(#250)); +#252=IFCREPRESENTATIONMAP(#239,#251); +#253=IFCTYPEPRODUCT('0sws1hxNb2Og1etXqyoEh1',$,'NAME-TAG',$,'IfcAnnotation/TEXT',(#235),(#252),$); +#254=IFCBEAMTYPE('2E$V5l4b54dxuToJ1A6IHp',$,'B1',$,$,$,$,$,$,.NOTDEFINED.); +#255=IFCRELASSOCIATESMATERIAL('3PUNnY7cj8LhyXbHKVqZnf',$,$,$,(#254),#259); +#256=IFCMATERIAL('Unknown',$,$); +#257=IFCISHAPEPROFILEDEF(.AREA.,'DEMO-I',$,100.,200.,5.,10.,5.,$,$); +#258=IFCMATERIALPROFILE($,$,#256,#257,$,$); +#259=IFCMATERIALPROFILESET($,$,(#258),$); +#260=IFCBEAMTYPE('3qutoZTvP0lvLWNXyjPhPm',$,'B2',$,$,$,$,$,$,.NOTDEFINED.); +#261=IFCRELASSOCIATESMATERIAL('3dGyQf42H2ahch2bSYPPHP',$,$,$,(#260),#264); +#262=IFCCSHAPEPROFILEDEF(.AREA.,'DEMO-C',$,200.,100.,1.5,30.,5.); +#263=IFCMATERIALPROFILE($,$,#256,#262,$,$); +#264=IFCMATERIALPROFILESET($,$,(#263),$); +#265=IFCCOLUMNTYPE('278sjptdDDzgOZseERj390',$,'C1',$,$,$,$,$,$,.NOTDEFINED.); +#266=IFCRELASSOCIATESMATERIAL('1ppYXw39v6kBwi3rrIm4ZE',$,$,$,(#265),#269); +#267=IFCRECTANGLEPROFILEDEF(.AREA.,'500x600',$,500.,600.); +#268=IFCMATERIALPROFILE($,$,#256,#267,$,$); +#269=IFCMATERIALPROFILESET($,$,(#268),$); +#270=IFCCOLUMNTYPE('3aSZOvGmr7jggSNwsq5PJE',$,'C2',$,$,$,$,$,$,.NOTDEFINED.); +#271=IFCRELASSOCIATESMATERIAL('1AtNJPaD14DgINbAjXvbU4',$,$,$,(#270),#274); +#272=IFCCIRCLEHOLLOWPROFILEDEF(.AREA.,'500.0x5.0 CHS',$,250.,5.); +#273=IFCMATERIALPROFILE($,$,#256,#272,$,$); +#274=IFCMATERIALPROFILESET($,$,(#273),$); +#275=IFCCOLUMNTYPE('28iv3Kru12yQ7R7RRwNjvD',$,'C3',$,$,$,$,$,$,.NOTDEFINED.); +#276=IFCRELASSOCIATESMATERIAL('2M_oL$n3j6rBRnOW5RSk87',$,$,$,(#275),#279); +#277=IFCRECTANGLEHOLLOWPROFILEDEF(.AREA.,'150x75x2.0 RHS',$,75.,150.,2.,5.,5.); +#278=IFCMATERIALPROFILE($,$,#256,#277,$,$); +#279=IFCMATERIALPROFILESET($,$,(#278),$); +#280=IFCCOVERINGTYPE('0iLxgfHB9F2hRH2vMcw7Yv',$,'COV10',$,$,$,$,$,$,.NOTDEFINED.); +#281=IFCRELASSOCIATESMATERIAL('0yvFX0zgb9uvJ3ARnf4mfe',$,$,$,(#280),#283); +#282=IFCMATERIALLAYER(#256,10.,$,$,$,$,$); +#283=IFCMATERIALLAYERSET((#282),$,$); +#284=IFCPROPERTYSINGLEVALUE('LayerSetDirection',$,IFCLABEL('AXIS2'),$); +#285=IFCPROPERTYSET('18ICCZ0fjDG9UXMyzrsFBA',$,'EPset_Parametric',$,(#284)); +#286=IFCCOVERINGTYPE('15Riw2WTrAUPwvfLyKXJ2g',$,'COV20',$,$,(#285),$,$,$,.NOTDEFINED.); +#287=IFCRELASSOCIATESMATERIAL('1iAOG6NK9FBxitFtzN9qP5',$,$,$,(#286),#289); +#288=IFCMATERIALLAYER(#256,20.,$,$,$,$,$); +#289=IFCMATERIALLAYERSET((#288),$,$); +#290=IFCPROPERTYSINGLEVALUE('LayerSetDirection',$,IFCLABEL('AXIS3'),$); +#291=IFCPROPERTYSET('3qCbs1tuDCvuOXam5RrA$a',$,'EPset_Parametric',$,(#290)); +#292=IFCCOVERINGTYPE('0gEOtYULD0F9KD9zPoA6cJ',$,'COV30',$,$,(#291),$,$,$,.NOTDEFINED.); +#293=IFCRELASSOCIATESMATERIAL('0xd6dsjf13JA98q9fnAMpQ',$,$,$,(#292),#295); +#294=IFCMATERIALLAYER(#256,30.,$,$,$,$,$); +#295=IFCMATERIALLAYERSET((#294),$,$); +#296=IFCCARTESIANPOINT((0.,0.,0.)); +#297=IFCDIRECTION((0.,0.,1.)); +#298=IFCDIRECTION((1.,0.,0.)); +#299=IFCAXIS2PLACEMENT3D(#296,#297,#298); +#306=IFCCARTESIANPOINTLIST3D(((955.000162124634,0.,2090.00015258789),(955.000162124634,54.9999885261059,2090.00015258789),(970.000028610229,54.9999922513962,2105.00001907349),(0.,99.9999940395355,0.),(970.000028610229,99.9999940395355,2105.00001907349),(39.9999916553497,99.9999940395355,2105.00001907349),(39.9999916553497,54.9999922513962,2105.00001907349),(55.0000071525574,54.9999885261059,2090.00015258789),(55.0000071525574,0.,2090.00015258789),(0.,0.,2145.00021934509),(0.,100.000001490116,2145.00021934509),(44.9999868869781,99.9999940395355,2099.99990463257),(44.9999868869781,59.9999949336052,2099.99990463257),(965.000033378601,59.9999949336052,2099.99990463257),(965.000033378601,99.9999940395355,2099.99990463257),(965.000033378601,99.9999940395355,0.),(965.000033378601,59.9999949336052,0.),(44.9999868869781,59.9999949336052,0.),(44.9999868869781,99.9999940395355,0.),(0.,0.,0.),(55.0000071525574,0.,0.),(55.0000071525574,54.9999922513962,0.),(39.9999916553497,54.9999922513962,0.),(39.9999916553497,99.9999940395355,0.),(1010.00034809113,0.,2145.00021934509),(1010.00034809113,100.000001490116,2145.00021934509),(955.000162124634,0.,0.),(955.000162124634,54.9999885261059,0.),(970.000028610229,54.9999922513962,0.),(970.000028610229,99.9999940395355,0.),(1010.00034809113,0.,0.),(1010.00034809113,100.000001490116,0.))); +#307=IFCINDEXEDPOLYGONALFACE((2,3,29,28)); +#308=IFCINDEXEDPOLYGONALFACE((27,28,29,30,32,31)); +#309=IFCINDEXEDPOLYGONALFACE((7,6,5,3)); +#310=IFCINDEXEDPOLYGONALFACE((8,7,3,2)); +#311=IFCINDEXEDPOLYGONALFACE((23,24,6,7)); +#312=IFCINDEXEDPOLYGONALFACE((21,20,4,24,23,22)); +#313=IFCINDEXEDPOLYGONALFACE((11,10,25,26)); +#314=IFCINDEXEDPOLYGONALFACE((25,1,27,31)); +#315=IFCINDEXEDPOLYGONALFACE((24,4,11,6)); +#316=IFCINDEXEDPOLYGONALFACE((20,21,9,10)); +#317=IFCINDEXEDPOLYGONALFACE((9,8,2,1)); +#318=IFCINDEXEDPOLYGONALFACE((10,9,1,25)); +#319=IFCINDEXEDPOLYGONALFACE((22,23,7,8)); +#320=IFCINDEXEDPOLYGONALFACE((4,20,10,11)); +#321=IFCINDEXEDPOLYGONALFACE((21,22,8,9)); +#322=IFCINDEXEDPOLYGONALFACE((6,11,26,5)); +#323=IFCINDEXEDPOLYGONALFACE((5,26,32,30)); +#324=IFCINDEXEDPOLYGONALFACE((1,2,28,27)); +#325=IFCINDEXEDPOLYGONALFACE((26,25,31,32)); +#326=IFCINDEXEDPOLYGONALFACE((3,5,30,29)); +#327=IFCPOLYGONALFACESET(#306,.T.,(#307,#308,#309,#310,#311,#312,#313,#314,#315,#316,#317,#318,#319,#320,#321,#322,#323,#324,#325,#326),$); +#328=IFCINDEXEDPOLYGONALFACE((17,16,15,14)); +#329=IFCINDEXEDPOLYGONALFACE((12,13,14,15)); +#330=IFCINDEXEDPOLYGONALFACE((16,19,12,15)); +#331=IFCINDEXEDPOLYGONALFACE((19,16,17,18)); +#332=IFCINDEXEDPOLYGONALFACE((19,18,13,12)); +#333=IFCINDEXEDPOLYGONALFACE((18,17,14,13)); +#334=IFCPOLYGONALFACESET(#306,.T.,(#328,#329,#330,#331,#332,#333),$); +#335=IFCSHAPEREPRESENTATION(#11,'Body','Tessellation',(#327,#334)); +#336=IFCREPRESENTATIONMAP(#299,#335); +#337=IFCCARTESIANPOINT((0.,0.,0.)); +#338=IFCDIRECTION((0.,0.,1.)); +#339=IFCDIRECTION((1.,0.,0.)); +#340=IFCAXIS2PLACEMENT3D(#337,#338,#339); +#346=IFCCARTESIANPOINTLIST2D(((964.999914169312,1020.0001001358),(965.000033378601,99.9999940395355),(925.000011920929,99.9999940395355),(924.999952316284,1020.0001001358),(964.999914169312,1020.0001001358),(844.915807247162,1012.12930679321),(726.886332035065,988.651752471924),(612.931072711945,949.969172477722),(504.999756813049,896.743297576904),(404.939234256744,829.885005950928),(314.461469650269,750.538170337677),(235.114604234695,660.060405731201),(168.256282806396,559.999823570251),(115.030474960804,452.068567276001),(76.3478726148605,338.113307952881),(52.8703518211842,220.083817839622),(44.9996180832386,99.999688565731))); +#347=IFCINDEXEDPOLYCURVE(#346,$,$); +#348=IFCCARTESIANPOINTLIST2D(((970.000028610229,54.9999922513962),(955.000162124634,54.9999922513962),(955.000162124634,0.),(1010.00034809113,0.),(1010.00034809113,99.9999940395355),(970.000028610229,99.9999940395355))); +#349=IFCINDEXEDPOLYCURVE(#348,(IFCLINEINDEX((1,2,3,4,5,6,1))),$); +#350=IFCCARTESIANPOINTLIST2D(((0.,0.),(0.,99.9999940395355),(39.9999916553497,99.9999940395355),(39.9999916553497,54.9999922513962),(55.0000071525574,54.9999922513962),(55.0000071525574,0.))); +#351=IFCINDEXEDPOLYCURVE(#350,(IFCLINEINDEX((1,2,3,4,5,6,1))),$); +#352=IFCGEOMETRICCURVESET((#347,#349,#351)); +#353=IFCSHAPEREPRESENTATION(#24,'Body','Annotation2D',(#352)); +#354=IFCREPRESENTATIONMAP(#340,#353); +#355=IFCDOORTYPE('2j0vIBgkH0ERnBCP0cpcCz',$,'DT01',$,$,$,(#336,#354),$,$,.NOTDEFINED.,.NOTDEFINED.,$,$); +#356=IFCSTYLEDITEM(#327,(#359),'Frame'); +#357=IFCCOLOURRGB($,0.0429765619337559,0.0429765619337559,0.0429765619337559); +#358=IFCSURFACESTYLESHADING(#357,0.); +#359=IFCSURFACESTYLE('Frame',.BOTH.,(#358)); +#360=IFCSTYLEDITEM(#334,(#363),'Panel'); +#361=IFCCOLOURRGB($,0.184475064277649,0.184475019574165,0.184475019574165); +#362=IFCSURFACESTYLESHADING(#361,0.); +#363=IFCSURFACESTYLE('Panel',.BOTH.,(#362)); +#364=IFCPILETYPE('25MTkMtaH5QeCf$gFbvSSw',$,'P1',$,$,$,$,$,$,.NOTDEFINED.); +#365=IFCRELASSOCIATESMATERIAL('09FXUZCnrFEffxBrtg16gx',$,$,$,(#364),#368); +#366=IFCCIRCLEPROFILEDEF(.AREA.,$,$,300.); +#367=IFCMATERIALPROFILE($,$,#256,#366,$,$); +#368=IFCMATERIALPROFILESET($,$,(#367),$); +#369=IFCRAMPTYPE('2yW1ePlyb8cOR6HdEntpRh',$,'RAM200',$,$,$,$,$,$,.NOTDEFINED.); +#370=IFCRELASSOCIATESMATERIAL('1x9ZyVim19AQtJhfEE06XR',$,$,$,(#369),#372); +#371=IFCMATERIALLAYER(#256,200.,$,$,$,$,$); +#372=IFCMATERIALLAYERSET((#371),$,$); +#373=IFCSLABTYPE('1Akbal7tP5DAPW0ti$Qp30',$,'FLR200',$,$,$,$,$,$,.NOTDEFINED.); +#374=IFCRELASSOCIATESMATERIAL('0jzkXlkBL4HhmQ_FTWW6Cj',$,$,$,(#373),#376); +#375=IFCMATERIALLAYER(#256,200.,$,$,$,$,$); +#376=IFCMATERIALLAYERSET((#375),$,$); +#377=IFCSLABTYPE('0$gpYNi3T08BrxaR67CxgI',$,'FLR300',$,$,$,$,$,$,.NOTDEFINED.); +#378=IFCRELASSOCIATESMATERIAL('3a6kjEM29DleYTlSkA8_DK',$,$,$,(#377),#380); +#379=IFCMATERIALLAYER(#256,300.,$,$,$,$,$); +#380=IFCMATERIALLAYERSET((#379),$,$); +#381=IFCWALLTYPE('2F1k78lI53fwS0rg3rqrRb',$,'WAL50',$,$,$,$,$,$,.NOTDEFINED.); +#382=IFCRELASSOCIATESMATERIAL('0khP_Smdj12f2JrOTRbRKj',$,$,$,(#381),#384); +#383=IFCMATERIALLAYER(#256,50.,$,$,$,$,$); +#384=IFCMATERIALLAYERSET((#383),$,$); +#385=IFCWALLTYPE('3R5onkTtX1IxdCMPoThRaw',$,'WAL100',$,$,$,$,$,$,.NOTDEFINED.); +#386=IFCRELASSOCIATESMATERIAL('0$mXRPCT16nP6Q70XFNvZv',$,$,$,(#385),#388); +#387=IFCMATERIALLAYER(#256,100.,$,$,$,$,$); +#388=IFCMATERIALLAYERSET((#387),$,$); +#389=IFCWALLTYPE('1juanculbDJPqvqT2FCm8m',$,'WAL200',$,$,$,$,$,$,.NOTDEFINED.); +#390=IFCRELASSOCIATESMATERIAL('0FsB2A$frFn9AZW2IG38ny',$,$,$,(#389),#392); +#391=IFCMATERIALLAYER(#256,200.,$,$,$,$,$); +#392=IFCMATERIALLAYERSET((#391),$,$); +#393=IFCWALLTYPE('05UYduR9r1vRxZZgP64Rdw',$,'WAL300',$,$,$,$,$,$,.NOTDEFINED.); +#394=IFCRELASSOCIATESMATERIAL('2FYhYrvmv4SBoZHVWDTTte',$,$,$,(#393),#396); +#395=IFCMATERIALLAYER(#256,300.,$,$,$,$,$); +#396=IFCMATERIALLAYERSET((#395),$,$); +#397=IFCCARTESIANPOINT((0.,0.,0.)); +#398=IFCDIRECTION((0.,0.,1.)); +#399=IFCDIRECTION((1.,0.,0.)); +#400=IFCAXIS2PLACEMENT3D(#397,#398,#399); +#407=IFCCARTESIANPOINTLIST3D(((899.999976158142,0.,1200.00004768372),(899.999976158142,0.,0.),(0.,0.,1200.00004768372),(0.,0.,0.),(99.9999940395355,0.,99.9999940395355),(99.9999940395355,0.,1100.00002384186),(800.000011920929,0.,1100.00002384186),(800.000011920929,0.,99.9999940395355),(99.9999940395355,19.9999995529652,99.9999940395355),(99.9999940395355,19.9999995529652,1100.00002384186),(800.000011920929,19.9999995529652,1100.00002384186),(800.000011920929,19.9999995529652,99.9999940395355),(99.9999940395355,50.0000007450581,99.9999940395355),(99.9999940395355,50.0000007450581,1100.00002384186),(800.000011920929,50.0000007450581,1100.00002384186),(800.000011920929,50.0000007450581,99.9999940395355),(0.,50.0000007450581,0.),(0.,50.0000007450581,1200.00004768372),(899.999976158142,50.0000007450581,1200.00004768372),(899.999976158142,50.0000007450581,0.),(99.9999940395355,29.9999993294477,99.9999940395355),(99.9999940395355,29.9999993294477,1100.00002384186),(800.000011920929,29.9999993294477,1100.00002384186),(800.000011920929,29.9999993294477,99.9999940395355))); +#408=IFCINDEXEDPOLYGONALFACE((13,17,18,14)); +#409=IFCINDEXEDPOLYGONALFACE((5,6,3,4)); +#410=IFCINDEXEDPOLYGONALFACE((7,8,2,1)); +#411=IFCINDEXEDPOLYGONALFACE((6,7,1,3)); +#412=IFCINDEXEDPOLYGONALFACE((8,5,4,2)); +#413=IFCINDEXEDPOLYGONALFACE((15,19,20,16)); +#414=IFCINDEXEDPOLYGONALFACE((14,18,19,15)); +#415=IFCINDEXEDPOLYGONALFACE((16,20,17,13)); +#416=IFCINDEXEDPOLYGONALFACE((4,17,20,2)); +#417=IFCINDEXEDPOLYGONALFACE((2,20,19,1)); +#418=IFCINDEXEDPOLYGONALFACE((8,16,13,5)); +#419=IFCINDEXEDPOLYGONALFACE((7,15,16,8)); +#420=IFCINDEXEDPOLYGONALFACE((1,19,18,3)); +#421=IFCINDEXEDPOLYGONALFACE((3,18,17,4)); +#422=IFCINDEXEDPOLYGONALFACE((6,14,15,7)); +#423=IFCINDEXEDPOLYGONALFACE((5,13,14,6)); +#424=IFCPOLYGONALFACESET(#407,.T.,(#408,#409,#410,#411,#412,#413,#414,#415,#416,#417,#418,#419,#420,#421,#422,#423),$); +#425=IFCINDEXEDPOLYGONALFACE((12,11,10,9)); +#426=IFCINDEXEDPOLYGONALFACE((24,21,22,23)); +#427=IFCINDEXEDPOLYGONALFACE((11,23,22,10)); +#428=IFCINDEXEDPOLYGONALFACE((10,22,21,9)); +#429=IFCINDEXEDPOLYGONALFACE((9,21,24,12)); +#430=IFCINDEXEDPOLYGONALFACE((12,24,23,11)); +#431=IFCPOLYGONALFACESET(#407,.T.,(#425,#426,#427,#428,#429,#430),$); +#432=IFCSHAPEREPRESENTATION(#11,'Body','Tessellation',(#424,#431)); +#433=IFCREPRESENTATIONMAP(#400,#432); +#434=IFCCARTESIANPOINT((0.,0.,0.)); +#435=IFCDIRECTION((0.,0.,1.)); +#436=IFCDIRECTION((1.,0.,0.)); +#437=IFCAXIS2PLACEMENT3D(#434,#435,#436); +#443=IFCCARTESIANPOINTLIST2D(((100.000023841858,20.0000032782555),(800.000011920929,20.0000032782555),(800.000011920929,30.0000011920929),(100.000023841858,30.0000011920929))); +#444=IFCINDEXEDPOLYCURVE(#443,(IFCLINEINDEX((1,2,3,4,1))),$); +#445=IFCCARTESIANPOINTLIST2D(((899.999976158142,50.0000007450581),(800.000011920929,50.0000007450581),(800.000011920929,0.),(899.999976158142,0.))); +#446=IFCINDEXEDPOLYCURVE(#445,(IFCLINEINDEX((1,2,3,4,1))),$); +#447=IFCCARTESIANPOINTLIST2D(((0.,0.),(100.000023841858,0.),(100.000023841858,50.0000007450581),(0.,50.0000007450581))); +#448=IFCINDEXEDPOLYCURVE(#447,(IFCLINEINDEX((1,2,3,4,1))),$); +#449=IFCCARTESIANPOINTLIST2D(((100.000023841858,50.0000007450581),(800.000011920929,50.0000007450581))); +#450=IFCINDEXEDPOLYCURVE(#449,$,$); +#451=IFCCARTESIANPOINTLIST2D(((100.000023841858,0.),(800.000011920929,0.))); +#452=IFCINDEXEDPOLYCURVE(#451,$,$); +#453=IFCGEOMETRICCURVESET((#444,#446,#448,#450,#452)); +#454=IFCSHAPEREPRESENTATION(#24,'Body','Annotation2D',(#453)); +#455=IFCREPRESENTATIONMAP(#437,#454); +#456=IFCWINDOWTYPE('0bK3c4PWL3eOMNwkPN$rlg',$,'WT01',$,$,$,(#433,#455),$,$,.NOTDEFINED.,.NOTDEFINED.,$,$); +#457=IFCSTYLEDITEM(#424,(#359),'Frame'); +#458=IFCSTYLEDITEM(#431,(#461),'Glass'); +#459=IFCCOLOURRGB($,0.800000011920929,1.,1.); +#460=IFCSURFACESTYLESHADING(#459,0.799999997019768); +#461=IFCSURFACESTYLE('Glass',.BOTH.,(#460)); +#462=IFCCARTESIANPOINT((0.,0.,0.)); +#463=IFCDIRECTION((0.,0.,1.)); +#464=IFCDIRECTION((1.,0.,0.)); +#465=IFCAXIS2PLACEMENT3D(#462,#463,#464); +#472=IFCCARTESIANPOINTLIST3D(((-75.7642686367035,-12.1694896370173,220.662087202072),(-105.255022644997,-14.1069469973445,230.906546115875),(-164.038479328156,-96.2571799755096,263.201057910919),(-14.9683114141226,-43.4482358396053,228.664547204971),(-42.6693223416805,-12.0228659361601,222.334340214729),(78.8992568850517,-76.7349451780319,173.714026808739),(95.3715369105339,-40.9212671220303,169.86283659935),(-71.9772353768349,-94.9608311057091,171.763256192207),(73.5535696148872,-46.2111458182335,199.328601360321),(-160.245850682259,39.7466160356998,298.533588647842),(106.730677187443,-12.4975387006998,138.676866889),(13.9651391655207,-42.3045344650745,229.461222887039),(96.7235639691353,-14.4418459385633,168.111309409142),(-219.927728176117,-41.4205342531204,239.053592085838),(-198.184996843338,-74.2136090993881,172.668352723122),(-162.167191505432,-43.4498824179173,289.568781852722),(-189.809292554855,-71.6947764158249,281.713783740997),(15.2298724278808,-84.9794447422028,205.268412828445),(-123.513199388981,-45.2961064875126,264.716774225235),(-188.629180192947,-119.135543704033,233.101561665535),(-13.0218090489507,-65.1145428419113,222.954735159874),(-196.876853704453,11.9782146066427,138.698890805244),(43.1601963937283,-45.1620146632195,221.45189344883),(-216.075524687767,-16.599427908659,204.968154430389),(-58.2821778953075,22.4160328507423,331.800371408463),(-190.823614597321,-102.445237338543,260.164886713028),(-43.1380830705166,-99.1964489221573,176.975786685944),(-52.2686094045639,49.4366958737373,351.232975721359),(-89.5938724279404,32.2130136191845,318.689584732056),(13.082567602396,-66.8555349111557,223.062723875046),(-106.145963072777,-41.5130592882633,228.82467508316),(44.8657646775246,-77.6780471205711,203.667193651199),(-103.71295362711,-3.66749544627964,314.385384321213),(-213.60756456852,-16.9711355119944,233.581200242043),(-138.989388942719,-74.9303176999092,265.050023794174),(105.769321322441,-41.5658876299858,138.697892427444),(99.2072820663452,-67.7607133984566,138.679757714272),(-135.680645704269,-40.2409471571445,287.896603345871),(-174.96183514595,-42.5181090831757,74.3281096220016),(-161.954745650291,-12.9314502701163,289.540559053421),(-208.628505468369,-103.418782353401,201.527774333954),(64.0031322836876,-67.7034556865692,197.900995612144),(100.172616541386,12.6537960022688,138.708665966988),(-168.615952134132,48.2185557484627,307.22576379776),(-14.0691194683313,-84.7146064043045,205.532997846603),(70.2492073178291,-102.0467877388,138.582319021225),(-181.213811039925,99.2056727409363,328.065633773804),(-15.2021609246731,-112.156376242638,18.3885656297207),(16.2124074995518,-111.216500401497,21.827794611454),(-133.747041225433,-15.9911345690489,290.624916553497),(-216.561943292618,-70.9330290555954,202.728658914566),(-42.7242144942284,-42.6300838589668,222.017183899879),(-159.124106168747,-73.8818794488907,283.847242593765),(-103.956542909145,15.4779236763716,320.181280374527),(-136.982098221779,-102.321907877922,19.4435473531485),(-183.684900403023,39.6271869540215,295.159220695496),(-107.928916811943,-10.153891518712,291.135489940643),(-103.886745870113,-101.836994290352,18.0104468017817),(-46.1161360144615,-119.219377636909,138.967230916023),(-46.1340732872486,-61.420276761055,215.00451862812),(-211.329713463783,-16.9732719659805,138.692498207092),(-165.825873613358,17.0033983886242,294.365167617798),(-162.926822900772,16.7535953223705,259.086668491364),(44.605728238821,-98.5531806945801,171.382486820221),(-83.4082290530205,3.35463741794229,315.553486347198),(-159.71240401268,24.7225016355515,197.611734271049),(-164.89240527153,105.032727122307,322.820842266083),(-215.148985385895,-46.2404675781727,266.269713640213),(74.162483215332,41.4574705064297,138.786911964417),(14.2031144350767,-105.447888374329,170.478105545044),(14.1690038144588,-13.1895141676068,229.208543896675),(43.3205515146255,-101.634204387665,17.8499221801758),(-194.831639528275,8.55887122452259,198.67131114006),(-190.071240067482,8.37886054068804,263.859361410141),(14.6396514028311,50.3562577068806,171.330958604813),(-46.6328002512455,-78.9417400956154,203.323245048523),(-14.2267476767302,-15.7651714980602,228.64143550396),(-214.272990822792,-70.0500085949898,258.544147014618),(-18.7377445399761,23.4869290143251,211.539566516876),(-169.090524315834,130.419373512268,343.455374240875),(-73.0840340256691,-58.5213899612427,211.252138018608),(-211.533859372139,-42.9056100547314,138.715773820877),(-73.9177912473679,15.4376216232777,210.008263587952),(-73.77789914608,-73.5882744193077,200.627535581589),(-186.267927289009,-121.167339384556,205.986142158508),(89.2870724201202,16.3372419774532,167.569145560265),(-163.796290755272,38.7952998280525,138.641089200974),(-197.594255208969,-74.69642162323,138.668864965439),(-157.580107450485,132.616892457008,328.512966632843),(-73.5077708959579,39.3004417419434,326.341509819031),(-133.432641625404,-80.0390690565109,240.147277712822),(-161.642774939537,-107.512913644314,235.317841172218),(-103.187024593353,15.1489116251469,293.316811323166),(-131.257891654968,-96.2524563074112,88.3080363273621),(-97.7480411529541,54.0151223540306,138.882651925087),(-15.323237515986,-128.71652841568,138.334348797798),(102.820813655853,-72.0862969756126,78.2168358564377),(69.1742300987244,9.61552746593952,196.848139166832),(-78.4864947199821,-104.707300662994,24.4421008974314),(-129.387423396111,-83.7726294994354,201.711267232895),(100.28512775898,14.7631969302893,106.750056147575),(72.5274235010147,-73.3503252267838,16.2904672324657),(90.7945036888123,-63.1996393203735,166.820541024208),(-68.5850381851196,68.8069462776184,138.255223631859),(-43.0277064442635,-107.757613062859,22.122398018837),(102.449595928192,-65.0743395090103,27.8087817132473),(-12.3228346928954,-128.916323184967,51.6869872808456),(13.3168455213308,-126.367673277855,49.7013293206692),(-211.436733603477,-42.5778105854988,171.008050441742),(-135.128378868103,-73.7440511584282,28.781833127141),(-71.3493376970291,-97.4928066134453,48.680767416954),(-14.4545361399651,-107.40352421999,169.533520936966),(-52.0200654864311,-106.458351016045,46.8626022338867),(-38.3422300219536,-121.899470686913,53.7898242473602),(-135.303497314453,4.72360569983721,269.406676292419),(-222.012773156166,-43.5851588845253,201.951056718826),(-150.152832269669,70.6916153430939,296.226799488068),(-205.232128500938,-53.0128739774227,172.492980957031),(81.5067514777184,-84.2671692371368,46.3023483753204),(101.917430758476,-74.4422674179077,51.1590167880058),(-104.162633419037,-76.9466981291771,197.300210595131),(-165.175527334213,100.392691791058,295.828104019165),(62.4474883079529,-91.4158597588539,172.223627567291),(-69.6270391345024,37.1879562735558,345.104366540909),(-129.096910357475,-71.5842396020889,53.2362163066864),(-102.229714393616,-91.8472409248352,50.0270053744316),(32.8243598341942,-62.8630220890045,219.847500324249),(-92.9397568106651,-59.8123446106911,212.814390659332),(-140.351414680481,-65.1696026325226,281.688511371613),(-29.9176927655935,64.6412074565887,345.614969730377),(-210.334226489067,-19.161444157362,170.468419790268),(-189.835593104362,-14.7899463772774,284.663945436478),(-70.6062465906143,-35.3134833276272,219.783633947372),(-196.250692009926,-41.9037826359272,286.000579595566),(-189.289301633835,15.417193993926,167.268991470337),(-165.491297841072,119.253136217594,309.156060218811),(-188.711583614349,-42.2543436288834,85.7931450009346),(-137.549817562103,-17.5594426691532,48.555850982666),(-43.9321398735046,18.8035927712917,209.587976336479),(-166.142821311951,43.8390895724297,269.286632537842),(-100.659042596817,21.2050415575504,210.695147514343),(-165.524810552597,68.1574642658234,275.103896856308),(-131.917878985405,-43.2314537465572,46.9778589904308),(-39.3056124448776,-127.956256270409,80.5243328213692),(-14.8295955732465,-134.464859962463,78.124076128006),(15.6515818089247,-132.012516260147,77.5675550103188),(128.680378198624,-63.8554841279984,48.6980155110359),(11.726126074791,-126.89021229744,138.521879911423),(-104.669205844402,-97.3712056875229,78.6209478974342),(-72.2803771495819,-99.4613841176033,78.2437026500702),(-90.0976955890656,28.9249792695045,304.527103900909),(-131.665915250778,-80.5337652564049,72.9337483644485),(-178.88680100441,12.7522293478251,288.278430700302),(-131.906762719154,21.6084867715836,211.986422538757),(43.8910871744156,44.6652211248875,170.035198330879),(126.842275261879,-62.0891898870468,72.0244571566582),(-181.458547711372,72.0020085573196,305.15855550766),(-105.359517037868,10.6867477297783,222.205132246017),(-75.5681917071342,-105.624243617058,107.75239020586),(-130.771055817604,43.6740666627884,171.749204397202),(-133.024662733078,49.973726272583,138.679206371307),(-116.55567586422,-16.352504491806,262.825727462769),(-192.813113331795,9.62049700319767,228.011801838875),(-99.5994955301285,46.3632792234421,169.919461011887),(-15.3328543528914,77.56557315588,138.280719518661),(-14.9811441078782,54.4508099555969,170.514196157455),(-77.7326822280884,18.9591310918331,297.642737627029),(-42.9378487169743,52.6389256119728,171.193689107895),(-210.668057203293,-93.4961810708046,245.899826288223),(-162.400558590889,19.8477655649185,223.333954811096),(112.556174397469,-41.5905937552452,87.884321808815),(-98.4991043806076,34.1813936829567,196.81504368782),(-125.417664647102,9.07643139362335,292.186677455902),(12.7286352217197,71.5995132923126,138.794869184494),(-184.464573860168,-63.567191362381,91.7578190565109),(-159.845903515816,34.9735803902149,277.037382125854),(-163.954228162766,-73.273241519928,79.649306833744),(-130.220845341682,47.9081235826015,111.017473042011),(-105.627626180649,-103.251308202744,104.907594621181),(-44.7412990033627,-130.966305732727,105.820834636688),(-14.5897325128317,-137.667417526245,107.010833919048),(17.7259147167206,-133.680522441864,110.51332205534),(-204.10780608654,-15.498636290431,265.768945217133),(-163.662612438202,-96.3144749403,108.248025178909),(-133.774682879448,-102.946348488331,108.776144683361),(-152.653515338898,-93.793697655201,11.2244309857488),(-169.374197721481,76.9077241420746,315.95915555954),(-153.37011218071,49.5448186993599,289.855599403381),(-148.65180850029,93.5175195336342,306.516766548157),(-163.774311542511,-100.279614329338,138.708546757698),(-114.786863327026,-34.9755696952343,251.059830188751),(43.5214228928089,-123.003117740154,107.089169323444),(12.2568001970649,23.4032459557056,212.896287441254),(-132.915586233139,-105.148307979107,138.666361570358),(-103.796437382698,-104.18801009655,138.67013156414),(-72.1595510840416,-105.9859842062,138.681977987289),(41.2953048944473,-12.3581402003765,221.496060490608),(-69.7300583124161,50.7166534662247,170.578330755234),(44.1036224365234,-114.852353930473,138.935402035713),(-12.8488391637802,38.8977639377117,196.252673864365),(-124.916173517704,-6.59546442329884,306.106418371201),(-218.161851167679,-71.009561419487,230.814844369888),(-163.197606801987,-97.3011329770088,173.606932163239),(-106.259688735008,-96.0564464330673,167.294099926949),(-134.439319372177,-99.6981337666512,164.969086647034),(-160.570159554482,-110.724151134491,202.919006347656),(-120.365753769875,-5.49432123079896,253.050655126572),(-133.883744478226,10.6024611741304,233.26064646244),(-36.5464128553867,62.771737575531,351.498425006866),(-69.8662772774696,35.7129909098148,305.281817913055),(-135.447904467583,-87.4549821019173,184.239640831947),(-112.891294062138,6.57996907830238,271.908432245255),(-49.9069318175316,49.8133301734924,325.594484806061),(-135.738432407379,-100.006818771362,-7.45058059692383E-06),(12.3523958027363,-101.531967520714,-7.45058059692383E-06),(-102.930329740047,-98.7276136875153,-7.45058059692383E-06),(-158.383101224899,35.3976972401142,167.762398719788),(58.5155189037323,-88.7269079685211,16.9257298111916),(-202.236160635948,-44.0891794860363,107.780121266842),(126.52799487114,-42.4845181405544,31.7913927137852),(44.5115864276886,-111.490845680237,45.2388003468513),(17.8857706487179,35.9265469014645,199.328750371933),(68.5334727168083,-97.8689268231392,53.3365905284882),(138.488471508026,-43.2419404387474,49.3728704750538),(40.6565591692924,62.880277633667,138.536900281906),(87.1811881661415,-87.0387107133865,138.694822788239),(-50.5233928561211,30.0182458013296,313.426643610001),(43.5324311256409,-119.963906705379,79.2121887207031),(72.3142325878143,-100.660108029842,80.1471099257469),(88.0676060914993,-86.207315325737,78.484445810318),(136.276960372925,-40.5644066631794,78.633114695549),(73.5301449894905,46.2804175913334,105.18267005682),(-180.783584713936,120.272636413574,335.98318696022),(-155.802026391029,-42.164009064436,62.2472763061523),(-192.451253533363,-73.2510983943939,112.686090171337),(31.3579067587852,24.0139346569777,208.784699440002),(72.8883668780327,-103.513494133949,107.350297272205),(88.5002017021179,-88.5679498314857,105.739302933216),(100.790202617645,-71.3259652256966,106.83286935091),(109.439946711063,-42.6978133618832,107.300646603107),(-188.64569067955,-16.7884975671768,86.7345333099365),(-70.9428116679192,35.2016389369965,193.65206360817),(-35.7190407812595,61.5072995424271,335.724234580994),(44.7911284863949,14.4118629395962,-7.45058059692383E-06),(36.9860865175724,36.9828194379807,-7.45058059692383E-06),(46.1129434406757,-74.8821049928665,-7.45058059692383E-06),(104.031659662724,-13.5611081495881,14.8804550990462),(98.6066535115242,6.6530667245388,27.1508432924747),(103.960558772087,-42.0542061328888,15.0693515315652),(121.874935925007,-14.7962821647525,28.2622296363115),(69.6230307221413,34.0555869042873,168.976783752441),(72.9203075170517,15.480482019484,22.6278305053711),(-44.5376336574554,74.1409137845039,139.188349246979),(46.685803681612,46.0076108574867,19.2816369235516),(132.462680339813,-14.7683853283525,79.218864440918),(123.972199857235,5.19884005188942,47.1794344484806),(134.83801484108,-13.5693158954382,47.7543026208878),(101.557418704033,15.0842368602753,50.0984787940979),(-151.446789503098,125.798091292381,318.272113800049),(82.6703608036041,23.927254602313,46.4257299900055),(69.3408101797104,43.5765013098717,50.0893704593182),(-42.0871675014496,38.0131863057613,193.471923470497),(-97.1032008528709,61.6641864180565,-7.45058059692383E-06),(-13.0963791161776,64.698226749897,19.7515171021223),(-157.119512557983,8.03167372941971,-7.45058059692383E-06),(113.602519035339,-13.2037419825792,87.9008769989014),(-69.912314414978,66.078893840313,19.1369466483593),(38.9328189194202,35.1467467844486,194.373697042465),(76.8988505005836,42.0413166284561,78.8332372903824),(101.57422721386,13.4498169645667,77.3250162601471),(123.080961406231,3.95354814827442,69.4246292114258),(-211.960434913635,-102.200835943222,224.356546998024),(110.181555151939,-13.6255938559771,109.196342527866),(-102.282598614693,41.4383597671986,19.612405449152),(-172.445297241211,115.39913713932,340.771019458771),(-181.048646569252,112.369157373905,342.96378493309),(72.5264996290207,-15.2853392064571,200.319215655327),(-183.978870511055,70.9394812583923,317.676812410355),(-153.028383851051,-38.4657420217991,-7.45058059692383E-06),(-154.637187719345,-69.1222250461578,-7.45058059692383E-06),(-152.765303850174,-73.8510563969612,15.262059867382),(-153.248697519302,-91.9284746050835,-7.45058059692383E-06),(-161.92090511322,-14.5302480086684,-7.45058059692383E-06),(-161.076262593269,-14.9271814152598,17.3035766929388),(-139.386385679245,-48.0194091796875,20.1432537287474),(-154.07682955265,-33.6258858442307,15.4564278200269),(-141.747921705246,-15.8547051250935,28.8874395191669),(-56.3743449747562,-108.996540307999,73.7379342317581),(-46.1691729724407,89.0766233205795,110.146202147007),(-14.6415047347546,51.2426868081093,-7.45058059692383E-06),(-156.508177518845,8.72325897216797,12.7747664228082),(-93.2494476437569,62.2886717319489,15.7215017825365),(-134.241998195648,18.0515833199024,22.0324043184519),(-75.4619538784027,45.5531552433968,50.9162880480289),(-103.701874613762,27.3517612367868,51.7874732613564),(-131.066977977753,11.5249017253518,52.4038933217525),(-62.931016087532,69.2232176661491,53.6416172981262),(-132.335588335991,32.2872921824455,197.51612842083),(-45.2888980507851,76.0203972458839,47.2172982990742),(-163.926124572754,14.2420912161469,82.3174566030502),(-174.691706895828,-13.5900285094976,73.6509189009666),(-48.6402213573456,84.9898308515549,78.8175389170647),(-68.9510703086853,70.2485665678978,78.4279331564903),(-81.0153111815453,49.1584502160549,74.8984813690186),(-42.9749675095081,61.7619827389717,-7.45058059692383E-06),(34.9937379360199,6.42204098403454,219.141826033592),(-202.323064208031,-12.2631303966045,109.208643436432),(-188.646167516708,14.8954978212714,108.683586120605),(-74.4052901864052,73.5662579536438,106.222227215767),(-161.729156970978,38.1991006433964,108.166508376598),(-104.008600115776,45.3929454088211,-7.45058059692383E-06),(38.8389863073826,70.2219158411026,109.72835123539),(-41.2575826048851,68.8836574554443,20.4634200781584),(-132.600158452988,16.2683837115765,-7.45058059692383E-06),(41.9384241104126,64.3723532557487,48.7342029809952),(-23.0755694210529,90.2970731258392,106.796741485596),(12.2685618698597,50.3091886639595,-7.45058059692383E-06),(42.0029424130917,68.0971890687943,78.9963230490685),(-13.2175851613283,6.25489093363285,222.308561205864),(14.6723045036197,7.23757036030293,223.271667957306),(72.0149055123329,-12.0490025728941,2.31547281146049),(13.688700273633,64.2379224300385,26.2222941964865),(33.619936555624,59.9825419485569,30.1631242036819),(15.6846102327108,72.6122707128525,49.7567467391491),(-13.9973452314734,76.6579210758209,47.4896989762783),(-16.6601836681366,85.7705846428871,79.0435597300529),(12.7416122704744,78.7845030426979,77.6184424757957),(-137.325063347816,22.2998633980751,70.9470063447952),(-103.061355650425,39.2319709062576,82.869827747345),(-133.015736937523,38.7391112744808,90.4415026307106),(-151.931047439575,32.1191623806953,87.8717452287674),(12.5869233161211,80.6632563471794,105.742789804935),(-99.5742082595825,49.370177090168,106.232292950153),(-74.6603757143021,65.6085163354874,-7.45058059692383E-06),(12.2953318059444,17.735980451107,-7.45058059692383E-06),(-14.6934473887086,26.2711010873318,-7.45058059692383E-06),(-42.9374538362026,29.8651698976755,-7.45058059692383E-06),(-103.215932846069,13.7835666537285,-7.45058059692383E-06),(44.4422401487827,-12.9836350679398,-7.45058059692383E-06),(-74.6518895030022,31.6607765853405,-7.45058059692383E-06),(12.3018361628056,-12.7876792103052,-7.45058059692383E-06),(-14.7215090692043,-13.3242877200246,-7.45058059692383E-06),(-101.430043578148,-14.7481001913548,-7.45058059692383E-06),(-42.9213680326939,-15.1002155616879,-7.45058059692383E-06),(-132.630944252014,-13.4387537837029,-7.45058059692383E-06),(-74.6475011110306,-11.1579261720181,-7.45058059692383E-06),(46.1949594318867,-48.3818538486958,-7.45058059692383E-06),(12.3028568923473,-43.1565642356873,-7.45058059692383E-06),(67.6943361759186,-43.9984127879143,2.13921279646456),(-14.7214606404305,-41.9304519891739,-7.45058059692383E-06),(-42.9213680326939,-42.6230616867542,-7.45058059692383E-06),(-134.391859173775,-42.0995727181435,-7.45058059692383E-06),(12.3003236949444,-71.4240521192551,-7.45058059692383E-06),(-14.7217661142349,-71.9940662384033,-7.45058059692383E-06),(-74.6477097272873,-69.8381289839745,-7.45058059692383E-06),(-42.9213680326939,-72.1928924322128,-7.45058059692383E-06),(-101.144231855869,-71.8697011470795,-7.45058059692383E-06),(34.7950644791126,-96.686989068985,-7.45058059692383E-06),(-132.067084312439,-72.0017328858376,-7.45058059692383E-06),(-159.548789262772,-12.5050684437156,61.8688985705376),(-16.9071108102798,-107.485927641392,-7.45058059692383E-06),(-74.6394321322441,-103.576719760895,-7.45058059692383E-06),(-42.8757518529892,-105.996340513229,-7.45058059692383E-06),(-74.6474862098694,-41.8127365410328,-7.45058059692383E-06),(-101.288944482803,-45.6511229276657,-7.45058059692383E-06),(61.871238052845,24.5271548628807,191.577181220055),(-47.0216795802116,41.4715930819511,344.332307577133),(-35.1001992821693,58.2603961229324,352.131396532059),(-43.320570141077,42.2725304961205,325.726985931396),(-33.2878455519676,56.865319609642,334.871053695679),(-78.2285928726196,10.980136692524,334.277510643005),(-61.2197890877724,18.5103937983513,307.83212184906),(-87.6919776201248,26.8637835979462,333.815038204193),(-75.0949084758759,-1.58989988267422,216.51217341423),(-43.2584583759308,0.724630663171411,217.384174466133))); +#473=IFCINDEXEDPOLYGONALFACE((187,278,44)); +#474=IFCINDEXEDPOLYGONALFACE((21,52,60)); +#475=IFCINDEXEDPOLYGONALFACE((91,100,31)); +#476=IFCINDEXEDPOLYGONALFACE((162,19,191)); +#477=IFCINDEXEDPOLYGONALFACE((288,180,159)); +#478=IFCINDEXEDPOLYGONALFACE((241,219,307)); +#479=IFCINDEXEDPOLYGONALFACE((54,93,173)); +#480=IFCINDEXEDPOLYGONALFACE((60,45,21)); +#481=IFCINDEXEDPOLYGONALFACE((58,110,55)); +#482=IFCINDEXEDPOLYGONALFACE((64,18,70)); +#483=IFCINDEXEDPOLYGONALFACE((2,207,162)); +#484=IFCINDEXEDPOLYGONALFACE((10,176,188)); +#485=IFCINDEXEDPOLYGONALFACE((105,114,113)); +#486=IFCINDEXEDPOLYGONALFACE((220,106,249)); +#487=IFCINDEXEDPOLYGONALFACE((252,321,244)); +#488=IFCINDEXEDPOLYGONALFACE((162,57,19)); +#489=IFCINDEXEDPOLYGONALFACE((224,147,220)); +#490=IFCINDEXEDPOLYGONALFACE((90,373,124)); +#491=IFCINDEXEDPOLYGONALFACE((70,199,64)); +#492=IFCINDEXEDPOLYGONALFACE((256,248,258)); +#493=IFCINDEXEDPOLYGONALFACE((115,212,207)); +#494=IFCINDEXEDPOLYGONALFACE((103,36,7)); +#495=IFCINDEXEDPOLYGONALFACE((71,306,320)); +#496=IFCINDEXEDPOLYGONALFACE((297,267,294)); +#497=IFCINDEXEDPOLYGONALFACE((57,50,19)); +#498=IFCINDEXEDPOLYGONALFACE((117,44,188)); +#499=IFCINDEXEDPOLYGONALFACE((62,56,153)); +#500=IFCINDEXEDPOLYGONALFACE((106,147,120)); +#501=IFCINDEXEDPOLYGONALFACE((254,244,245)); +#502=IFCINDEXEDPOLYGONALFACE((208,207,2)); +#503=IFCINDEXEDPOLYGONALFACE((256,257,250)); +#504=IFCINDEXEDPOLYGONALFACE((203,205,211)); +#505=IFCINDEXEDPOLYGONALFACE((56,278,157)); +#506=IFCINDEXEDPOLYGONALFACE((103,7,9)); +#507=IFCINDEXEDPOLYGONALFACE((63,140,176)); +#508=IFCINDEXEDPOLYGONALFACE((15,109,118)); +#509=IFCINDEXEDPOLYGONALFACE((59,159,180)); +#510=IFCINDEXEDPOLYGONALFACE((158,154,208)); +#511=IFCINDEXEDPOLYGONALFACE((300,241,308)); +#512=IFCINDEXEDPOLYGONALFACE((23,32,42)); +#513=IFCINDEXEDPOLYGONALFACE((44,278,56)); +#514=IFCINDEXEDPOLYGONALFACE((189,259,67)); +#515=IFCINDEXEDPOLYGONALFACE((309,304,333)); +#516=IFCINDEXEDPOLYGONALFACE((136,89,259)); +#517=IFCINDEXEDPOLYGONALFACE((31,191,19)); +#518=IFCINDEXEDPOLYGONALFACE((295,304,294)); +#519=IFCINDEXEDPOLYGONALFACE((50,38,19)); +#520=IFCINDEXEDPOLYGONALFACE((44,62,10)); +#521=IFCINDEXEDPOLYGONALFACE((369,25,227)); +#522=IFCINDEXEDPOLYGONALFACE((136,47,233)); +#523=IFCINDEXEDPOLYGONALFACE((33,54,201)); +#524=IFCINDEXEDPOLYGONALFACE((333,304,329)); +#525=IFCINDEXEDPOLYGONALFACE((281,110,285)); +#526=IFCINDEXEDPOLYGONALFACE((275,80,276)); +#527=IFCINDEXEDPOLYGONALFACE((119,106,120)); +#528=IFCINDEXEDPOLYGONALFACE((276,80,233)); +#529=IFCINDEXEDPOLYGONALFACE((232,318,312)); +#530=IFCINDEXEDPOLYGONALFACE((208,63,115)); +#531=IFCINDEXEDPOLYGONALFACE((150,288,159)); +#532=IFCINDEXEDPOLYGONALFACE((286,287,284)); +#533=IFCINDEXEDPOLYGONALFACE((286,285,287)); +#534=IFCINDEXEDPOLYGONALFACE((285,286,279)); +#535=IFCINDEXEDPOLYGONALFACE((239,171,240)); +#536=IFCINDEXEDPOLYGONALFACE((233,47,276)); +#537=IFCINDEXEDPOLYGONALFACE((124,213,90)); +#538=IFCINDEXEDPOLYGONALFACE((157,278,47)); +#539=IFCINDEXEDPOLYGONALFACE((187,47,157)); +#540=IFCINDEXEDPOLYGONALFACE((268,75,222)); +#541=IFCINDEXEDPOLYGONALFACE((101,269,232)); +#542=IFCINDEXEDPOLYGONALFACE((277,7,13)); +#543=IFCINDEXEDPOLYGONALFACE((140,63,74)); +#544=IFCINDEXEDPOLYGONALFACE((140,74,56)); +#545=IFCINDEXEDPOLYGONALFACE((74,153,56)); +#546=IFCINDEXEDPOLYGONALFACE((57,201,50)); +#547=IFCINDEXEDPOLYGONALFACE((320,236,193)); +#548=IFCINDEXEDPOLYGONALFACE((222,236,268)); +#549=IFCINDEXEDPOLYGONALFACE((173,50,201)); +#550=IFCINDEXEDPOLYGONALFACE((299,267,297)); +#551=IFCINDEXEDPOLYGONALFACE((162,212,57)); +#552=IFCINDEXEDPOLYGONALFACE((208,115,207)); +#553=IFCINDEXEDPOLYGONALFACE((267,292,274)); +#554=IFCINDEXEDPOLYGONALFACE((98,197,277)); +#555=IFCINDEXEDPOLYGONALFACE((295,328,329)); +#556=IFCINDEXEDPOLYGONALFACE((158,208,2)); +#557=IFCINDEXEDPOLYGONALFACE((201,57,33)); +#558=IFCINDEXEDPOLYGONALFACE((187,47,278)); +#559=IFCINDEXEDPOLYGONALFACE((241,307,308)); +#560=IFCINDEXEDPOLYGONALFACE((335,317,245)); +#561=IFCINDEXEDPOLYGONALFACE((328,330,329)); +#562=IFCINDEXEDPOLYGONALFACE((84,128,121)); +#563=IFCINDEXEDPOLYGONALFACE((331,330,328)); +#564=IFCINDEXEDPOLYGONALFACE((300,331,328)); +#565=IFCINDEXEDPOLYGONALFACE((129,19,38)); +#566=IFCINDEXEDPOLYGONALFACE((154,298,66)); +#567=IFCINDEXEDPOLYGONALFACE((317,322,323)); +#568=IFCINDEXEDPOLYGONALFACE((302,297,303)); +#569=IFCINDEXEDPOLYGONALFACE((212,93,167)); +#570=IFCINDEXEDPOLYGONALFACE((94,185,184)); +#571=IFCINDEXEDPOLYGONALFACE((211,121,100)); +#572=IFCINDEXEDPOLYGONALFACE((212,173,93)); +#573=IFCINDEXEDPOLYGONALFACE((317,254,245)); +#574=IFCINDEXEDPOLYGONALFACE((51,15,41)); +#575=IFCINDEXEDPOLYGONALFACE((321,339,244)); +#576=IFCINDEXEDPOLYGONALFACE((244,335,245)); +#577=IFCINDEXEDPOLYGONALFACE((211,204,121)); +#578=IFCINDEXEDPOLYGONALFACE((246,72,358)); +#579=IFCINDEXEDPOLYGONALFACE((300,360,301)); +#580=IFCINDEXEDPOLYGONALFACE((234,177,39)); +#581=IFCINDEXEDPOLYGONALFACE((125,152,177)); +#582=IFCINDEXEDPOLYGONALFACE((338,314,311)); +#583=IFCINDEXEDPOLYGONALFACE((149,94,152)); +#584=IFCINDEXEDPOLYGONALFACE((39,175,137)); +#585=IFCINDEXEDPOLYGONALFACE((334,292,267)); +#586=IFCINDEXEDPOLYGONALFACE((343,338,340,346)); +#587=IFCINDEXEDPOLYGONALFACE((283,286,284)); +#588=IFCINDEXEDPOLYGONALFACE((129,16,53)); +#589=IFCINDEXEDPOLYGONALFACE((102,249,106)); +#590=IFCINDEXEDPOLYGONALFACE((197,12,23)); +#591=IFCINDEXEDPOLYGONALFACE((330,310,178)); +#592=IFCINDEXEDPOLYGONALFACE((307,61,22,308)); +#593=IFCINDEXEDPOLYGONALFACE((300,310,331)); +#594=IFCINDEXEDPOLYGONALFACE((205,190,194)); +#595=IFCINDEXEDPOLYGONALFACE((133,2,31)); +#596=IFCINDEXEDPOLYGONALFACE((85,92,20)); +#597=IFCINDEXEDPOLYGONALFACE((360,39,301)); +#598=IFCINDEXEDPOLYGONALFACE((122,47,136)); +#599=IFCINDEXEDPOLYGONALFACE((281,282,186)); +#600=IFCINDEXEDPOLYGONALFACE((2,191,31)); +#601=IFCINDEXEDPOLYGONALFACE((250,249,247)); +#602=IFCINDEXEDPOLYGONALFACE((58,214,216)); +#603=IFCINDEXEDPOLYGONALFACE((234,138,143)); +#604=IFCINDEXEDPOLYGONALFACE((141,298,154)); +#605=IFCINDEXEDPOLYGONALFACE((27,45,76)); +#606=IFCINDEXEDPOLYGONALFACE((146,181,145)); +#607=IFCINDEXEDPOLYGONALFACE((144,181,180)); +#608=IFCINDEXEDPOLYGONALFACE((195,185,179)); +#609=IFCINDEXEDPOLYGONALFACE((228,223,229)); +#610=IFCINDEXEDPOLYGONALFACE((49,358,72)); +#611=IFCINDEXEDPOLYGONALFACE((74,34,183)); +#612=IFCINDEXEDPOLYGONALFACE((221,218,223)); +#613=IFCINDEXEDPOLYGONALFACE((146,107,108)); +#614=IFCINDEXEDPOLYGONALFACE((194,204,205)); +#615=IFCINDEXEDPOLYGONALFACE((352,359,280,279)); +#616=IFCINDEXEDPOLYGONALFACE((46,64,199)); +#617=IFCINDEXEDPOLYGONALFACE((366,86,251)); +#618=IFCINDEXEDPOLYGONALFACE((48,114,105)); +#619=IFCINDEXEDPOLYGONALFACE((198,95,164)); +#620=IFCINDEXEDPOLYGONALFACE((372,65,167)); +#621=IFCINDEXEDPOLYGONALFACE((74,132,153)); +#622=IFCINDEXEDPOLYGONALFACE((21,12,4)); +#623=IFCINDEXEDPOLYGONALFACE((288,111,113)); +#624=IFCINDEXEDPOLYGONALFACE((75,225,174)); +#625=IFCINDEXEDPOLYGONALFACE((166,262,200)); +#626=IFCINDEXEDPOLYGONALFACE((223,230,229)); +#627=IFCINDEXEDPOLYGONALFACE((26,92,3)); +#628=IFCINDEXEDPOLYGONALFACE((219,88,82)); +#629=IFCINDEXEDPOLYGONALFACE((355,357,365,364)); +#630=IFCINDEXEDPOLYGONALFACE((322,325,324)); +#631=IFCINDEXEDPOLYGONALFACE((257,220,250)); +#632=IFCINDEXEDPOLYGONALFACE((289,104,253)); +#633=IFCINDEXEDPOLYGONALFACE((228,108,221)); +#634=IFCINDEXEDPOLYGONALFACE((119,218,102)); +#635=IFCINDEXEDPOLYGONALFACE((367,124,25)); +#636=IFCINDEXEDPOLYGONALFACE((327,325,326)); +#637=IFCINDEXEDPOLYGONALFACE((40,115,63)); +#638=IFCINDEXEDPOLYGONALFACE((321,248,247)); +#639=IFCINDEXEDPOLYGONALFACE((158,83,141)); +#640=IFCINDEXEDPOLYGONALFACE((13,98,277)); +#641=IFCINDEXEDPOLYGONALFACE((352,345,343,365)); +#642=IFCINDEXEDPOLYGONALFACE((5,374,1)); +#643=IFCINDEXEDPOLYGONALFACE((339,347,348,341)); +#644=IFCINDEXEDPOLYGONALFACE((135,87,22)); +#645=IFCINDEXEDPOLYGONALFACE((156,224,231)); +#646=IFCINDEXEDPOLYGONALFACE((163,63,170)); +#647=IFCINDEXEDPOLYGONALFACE((56,142,140)); +#648=IFCINDEXEDPOLYGONALFACE((362,355,356,363)); +#649=IFCINDEXEDPOLYGONALFACE((88,203,15)); +#650=IFCINDEXEDPOLYGONALFACE((24,163,73)); +#651=IFCINDEXEDPOLYGONALFACE((14,78,68)); +#652=IFCINDEXEDPOLYGONALFACE((248,260,258)); +#653=IFCINDEXEDPOLYGONALFACE((78,26,17)); +#654=IFCINDEXEDPOLYGONALFACE((16,17,53)); +#655=IFCINDEXEDPOLYGONALFACE((161,164,95)); +#656=IFCINDEXEDPOLYGONALFACE((291,287,293)); +#657=IFCINDEXEDPOLYGONALFACE((127,18,32)); +#658=IFCINDEXEDPOLYGONALFACE((182,199,148)); +#659=IFCINDEXEDPOLYGONALFACE((319,71,320)); +#660=IFCINDEXEDPOLYGONALFACE((225,232,312)); +#661=IFCINDEXEDPOLYGONALFACE((302,309,289)); +#662=IFCINDEXEDPOLYGONALFACE((13,36,11)); +#663=IFCINDEXEDPOLYGONALFACE((308,87,310)); +#664=IFCINDEXEDPOLYGONALFACE((353,348,347,246)); +#665=IFCINDEXEDPOLYGONALFACE((262,79,200)); +#666=IFCINDEXEDPOLYGONALFACE((131,73,135)); +#667=IFCINDEXEDPOLYGONALFACE((370,213,243)); +#668=IFCINDEXEDPOLYGONALFACE((92,100,91)); +#669=IFCINDEXEDPOLYGONALFACE((89,233,80)); +#670=IFCINDEXEDPOLYGONALFACE((332,165,174)); +#671=IFCINDEXEDPOLYGONALFACE((1,374,2)); +#672=IFCINDEXEDPOLYGONALFACE((28,368,209)); +#673=IFCINDEXEDPOLYGONALFACE((189,136,259)); +#674=IFCINDEXEDPOLYGONALFACE((326,332,327)); +#675=IFCINDEXEDPOLYGONALFACE((117,122,189)); +#676=IFCINDEXEDPOLYGONALFACE((132,16,40)); +#677=IFCINDEXEDPOLYGONALFACE((263,334,311)); +#678=IFCINDEXEDPOLYGONALFACE((134,183,68)); +#679=IFCINDEXEDPOLYGONALFACE((157,122,142)); +#680=IFCINDEXEDPOLYGONALFACE((239,230,97)); +#681=IFCINDEXEDPOLYGONALFACE((180,96,59)); +#682=IFCINDEXEDPOLYGONALFACE((99,113,111)); +#683=IFCINDEXEDPOLYGONALFACE((22,131,135)); +#684=IFCINDEXEDPOLYGONALFACE((321,249,349)); +#685=IFCINDEXEDPOLYGONALFACE((156,120,147)); +#686=IFCINDEXEDPOLYGONALFACE((148,181,182)); +#687=IFCINDEXEDPOLYGONALFACE((152,126,149)); +#688=IFCINDEXEDPOLYGONALFACE((346,340,337,344)); +#689=IFCINDEXEDPOLYGONALFACE((358,215,353,246)); +#690=IFCINDEXEDPOLYGONALFACE((275,89,80)); +#691=IFCINDEXEDPOLYGONALFACE((240,37,239)); +#692=IFCINDEXEDPOLYGONALFACE((14,183,34)); +#693=IFCINDEXEDPOLYGONALFACE((293,295,274)); +#694=IFCINDEXEDPOLYGONALFACE((350,351,344,342)); +#695=IFCINDEXEDPOLYGONALFACE((148,112,96)); +#696=IFCINDEXEDPOLYGONALFACE((313,325,264)); +#697=IFCINDEXEDPOLYGONALFACE((154,170,208)); +#698=IFCINDEXEDPOLYGONALFACE((226,123,46)); +#699=IFCINDEXEDPOLYGONALFACE((351,364,346,344)); +#700=IFCINDEXEDPOLYGONALFACE((355,362,216,357)); +#701=IFCINDEXEDPOLYGONALFACE((349,339,321)); +#702=IFCINDEXEDPOLYGONALFACE((318,324,327)); +#703=IFCINDEXEDPOLYGONALFACE((338,311,334,340)); +#704=IFCINDEXEDPOLYGONALFACE((326,299,302)); +#705=IFCINDEXEDPOLYGONALFACE((112,59,96)); +#706=IFCINDEXEDPOLYGONALFACE((262,198,242)); +#707=IFCINDEXEDPOLYGONALFACE((272,51,41)); +#708=IFCINDEXEDPOLYGONALFACE((318,261,315)); +#709=IFCINDEXEDPOLYGONALFACE((167,57,212)); +#710=IFCINDEXEDPOLYGONALFACE((271,266,255)); +#711=IFCINDEXEDPOLYGONALFACE((218,246,102)); +#712=IFCINDEXEDPOLYGONALFACE((94,179,185)); +#713=IFCINDEXEDPOLYGONALFACE((343,346,364,365)); +#714=IFCINDEXEDPOLYGONALFACE((40,153,132)); +#715=IFCINDEXEDPOLYGONALFACE((345,314,338,343)); +#716=IFCINDEXEDPOLYGONALFACE((8,121,204)); +#717=IFCINDEXEDPOLYGONALFACE((32,64,123)); +#718=IFCINDEXEDPOLYGONALFACE((88,109,82)); +#719=IFCINDEXEDPOLYGONALFACE((133,128,81)); +#720=IFCINDEXEDPOLYGONALFACE((193,319,320)); +#721=IFCINDEXEDPOLYGONALFACE((370,367,369)); +#722=IFCINDEXEDPOLYGONALFACE((6,9,42)); +#723=IFCINDEXEDPOLYGONALFACE((214,186,282)); +#724=IFCINDEXEDPOLYGONALFACE((200,75,166)); +#725=IFCINDEXEDPOLYGONALFACE((375,79,139)); +#726=IFCINDEXEDPOLYGONALFACE((95,309,333)); +#727=IFCINDEXEDPOLYGONALFACE((221,49,72)); +#728=IFCINDEXEDPOLYGONALFACE((36,273,11)); +#729=IFCINDEXEDPOLYGONALFACE((69,155,251)); +#730=IFCINDEXEDPOLYGONALFACE((316,302,289)); +#731=IFCINDEXEDPOLYGONALFACE((297,304,303)); +#732=IFCINDEXEDPOLYGONALFACE((195,159,196)); +#733=IFCINDEXEDPOLYGONALFACE((110,186,55)); +#734=IFCINDEXEDPOLYGONALFACE((323,324,315)); +#735=IFCINDEXEDPOLYGONALFACE((172,83,242)); +#736=IFCINDEXEDPOLYGONALFACE((61,219,82)); +#737=IFCINDEXEDPOLYGONALFACE((283,291,265)); +#738=IFCINDEXEDPOLYGONALFACE((184,175,177)); +#739=IFCINDEXEDPOLYGONALFACE((349,246,347)); +#740=IFCINDEXEDPOLYGONALFACE((174,166,75)); +#741=IFCINDEXEDPOLYGONALFACE((48,363,361)); +#742=IFCINDEXEDPOLYGONALFACE((199,237,46)); +#743=IFCINDEXEDPOLYGONALFACE((164,242,198)); +#744=IFCINDEXEDPOLYGONALFACE((290,317,335,336)); +#745=IFCINDEXEDPOLYGONALFACE((217,298,160)); +#746=IFCINDEXEDPOLYGONALFACE((193,200,79)); +#747=IFCINDEXEDPOLYGONALFACE((253,166,165)); +#748=IFCINDEXEDPOLYGONALFACE((202,116,51)); +#749=IFCINDEXEDPOLYGONALFACE((236,366,268)); +#750=IFCINDEXEDPOLYGONALFACE((170,73,163)); +#751=IFCINDEXEDPOLYGONALFACE((360,328,296)); +#752=IFCINDEXEDPOLYGONALFACE((354,350,348,353)); +#753=IFCINDEXEDPOLYGONALFACE((359,357,216,214)); +#754=IFCINDEXEDPOLYGONALFACE((143,110,125)); +#755=IFCINDEXEDPOLYGONALFACE((265,314,345,283)); +#756=IFCINDEXEDPOLYGONALFACE((252,261,260)); +#757=IFCINDEXEDPOLYGONALFACE((305,337,340,334)); +#758=IFCINDEXEDPOLYGONALFACE((131,116,24)); +#759=IFCINDEXEDPOLYGONALFACE((104,168,253)); +#760=IFCINDEXEDPOLYGONALFACE((126,99,111)); +#761=IFCINDEXEDPOLYGONALFACE((47,275,276)); +#762=IFCINDEXEDPOLYGONALFACE((230,120,97)); +#763=IFCINDEXEDPOLYGONALFACE((279,283,345,352)); +#764=IFCINDEXEDPOLYGONALFACE((67,89,275)); +#765=IFCINDEXEDPOLYGONALFACE((257,271,255)); +#766=IFCINDEXEDPOLYGONALFACE((257,231,224)); +#767=IFCINDEXEDPOLYGONALFACE((316,253,165)); +#768=IFCINDEXEDPOLYGONALFACE((17,3,53)); +#769=IFCINDEXEDPOLYGONALFACE((273,171,266)); +#770=IFCINDEXEDPOLYGONALFACE((260,270,258)); +#771=IFCINDEXEDPOLYGONALFACE((362,58,216)); +#772=IFCINDEXEDPOLYGONALFACE((48,108,107)); +#773=IFCINDEXEDPOLYGONALFACE((57,65,33)); +#774=IFCINDEXEDPOLYGONALFACE((160,172,164)); +#775=IFCINDEXEDPOLYGONALFACE((190,235,184)); +#776=IFCINDEXEDPOLYGONALFACE((354,353,215,361)); +#777=IFCINDEXEDPOLYGONALFACE((258,271,256)); +#778=IFCINDEXEDPOLYGONALFACE((155,366,251)); +#779=IFCINDEXEDPOLYGONALFACE((365,357,359,352)); +#780=IFCINDEXEDPOLYGONALFACE((169,20,26)); +#781=IFCINDEXEDPOLYGONALFACE((312,174,225)); +#782=IFCINDEXEDPOLYGONALFACE((273,43,11)); +#783=IFCINDEXEDPOLYGONALFACE((264,317,290)); +#784=IFCINDEXEDPOLYGONALFACE((287,296,293)); +#785=IFCINDEXEDPOLYGONALFACE((159,149,150)); +#786=IFCINDEXEDPOLYGONALFACE((267,305,334)); +#787=IFCINDEXEDPOLYGONALFACE((206,211,100)); +#788=IFCINDEXEDPOLYGONALFACE((126,150,149)); +#789=IFCINDEXEDPOLYGONALFACE((288,114,144)); +#790=IFCINDEXEDPOLYGONALFACE((266,101,273)); +#791=IFCINDEXEDPOLYGONALFACE((123,42,32)); +#792=IFCINDEXEDPOLYGONALFACE((255,171,231)); +#793=IFCINDEXEDPOLYGONALFACE((34,116,14)); +#794=IFCINDEXEDPOLYGONALFACE((91,3,92)); +#795=IFCINDEXEDPOLYGONALFACE((287,143,138)); +#796=IFCINDEXEDPOLYGONALFACE((77,12,71)); +#797=IFCINDEXEDPOLYGONALFACE((95,178,161)); +#798=IFCINDEXEDPOLYGONALFACE((285,280,281)); +#799=IFCINDEXEDPOLYGONALFACE((242,139,262)); +#800=IFCINDEXEDPOLYGONALFACE((332,318,327)); +#801=IFCINDEXEDPOLYGONALFACE((226,239,37)); +#802=IFCINDEXEDPOLYGONALFACE((175,219,137)); +#803=IFCINDEXEDPOLYGONALFACE((177,94,184)); +#804=IFCINDEXEDPOLYGONALFACE((103,226,37)); +#805=IFCINDEXEDPOLYGONALFACE((372,371,65)); +#806=IFCINDEXEDPOLYGONALFACE((341,335,244,339)); +#807=IFCINDEXEDPOLYGONALFACE((101,69,43)); +#808=IFCINDEXEDPOLYGONALFACE((146,192,182)); +#809=IFCINDEXEDPOLYGONALFACE((52,77,5)); +#810=IFCINDEXEDPOLYGONALFACE((133,60,52)); +#811=IFCINDEXEDPOLYGONALFACE((28,243,213)); +#812=IFCINDEXEDPOLYGONALFACE((110,126,125)); +#813=IFCINDEXEDPOLYGONALFACE((140,188,176)); +#814=IFCINDEXEDPOLYGONALFACE((341,342,336,335)); +#815=IFCINDEXEDPOLYGONALFACE((82,131,61)); +#816=IFCINDEXEDPOLYGONALFACE((290,336,337,305)); +#817=IFCINDEXEDPOLYGONALFACE((109,51,116)); +#818=IFCINDEXEDPOLYGONALFACE((210,29,90)); +#819=IFCINDEXEDPOLYGONALFACE((45,30,21)); +#820=IFCINDEXEDPOLYGONALFACE((204,196,8)); +#821=IFCINDEXEDPOLYGONALFACE((229,238,237)); +#822=IFCINDEXEDPOLYGONALFACE((161,217,160)); +#823=IFCINDEXEDPOLYGONALFACE((305,264,290)); +#824=IFCINDEXEDPOLYGONALFACE((84,60,81)); +#825=IFCINDEXEDPOLYGONALFACE((185,190,184)); +#826=IFCINDEXEDPOLYGONALFACE((5,133,52)); +#827=IFCINDEXEDPOLYGONALFACE((189,187,117)); +#828=IFCINDEXEDPOLYGONALFACE((226,237,238)); +#829=IFCINDEXEDPOLYGONALFACE((23,277,197)); +#830=IFCINDEXEDPOLYGONALFACE((76,8,27)); +#831=IFCINDEXEDPOLYGONALFACE((294,274,295)); +#832=IFCINDEXEDPOLYGONALFACE((145,114,107)); +#833=IFCINDEXEDPOLYGONALFACE((188,44,10)); +#834=IFCINDEXEDPOLYGONALFACE((41,203,85)); +#835=IFCINDEXEDPOLYGONALFACE((13,43,86)); +#836=IFCINDEXEDPOLYGONALFACE((355,364,351,356)); +#837=IFCINDEXEDPOLYGONALFACE((234,125,177)); +#838=IFCINDEXEDPOLYGONALFACE((40,38,50)); +#839=IFCINDEXEDPOLYGONALFACE((272,85,20)); +#840=IFCINDEXEDPOLYGONALFACE((215,48,361)); +#841=IFCINDEXEDPOLYGONALFACE((39,241,301)); +#842=IFCINDEXEDPOLYGONALFACE((311,292,263)); +#843=IFCINDEXEDPOLYGONALFACE((69,86,43)); +#844=IFCINDEXEDPOLYGONALFACE((310,161,178)); +#845=IFCINDEXEDPOLYGONALFACE((202,169,78)); +#846=IFCINDEXEDPOLYGONALFACE((248,250,247)); +#847=IFCINDEXEDPOLYGONALFACE((296,138,360)); +#848=IFCINDEXEDPOLYGONALFACE((42,9,23)); +#849=IFCINDEXEDPOLYGONALFACE((203,206,85)); +#850=IFCINDEXEDPOLYGONALFACE((202,272,169)); +#851=IFCINDEXEDPOLYGONALFACE((342,344,337,336)); +#852=IFCINDEXEDPOLYGONALFACE((129,35,19)); +#853=IFCINDEXEDPOLYGONALFACE((2,162,191)); +#854=IFCINDEXEDPOLYGONALFACE((366,306,98)); +#855=IFCINDEXEDPOLYGONALFACE((361,363,356,354)); +#856=IFCINDEXEDPOLYGONALFACE((68,17,134)); +#857=IFCINDEXEDPOLYGONALFACE((54,173,201)); +#858=IFCINDEXEDPOLYGONALFACE((210,167,151)); +#859=IFCINDEXEDPOLYGONALFACE((156,171,97)); +#860=IFCINDEXEDPOLYGONALFACE((54,151,93)); +#861=IFCINDEXEDPOLYGONALFACE((59,8,196)); +#862=IFCINDEXEDPOLYGONALFACE((213,210,90)); +#863=IFCINDEXEDPOLYGONALFACE((54,371,373)); +#864=IFCINDEXEDPOLYGONALFACE((130,243,209)); +#865=IFCINDEXEDPOLYGONALFACE((359,214,282,280)); +#866=IFCINDEXEDPOLYGONALFACE((142,117,188)); +#867=IFCINDEXEDPOLYGONALFACE((28,367,368)); +#868=IFCINDEXEDPOLYGONALFACE((237,228,229)); +#869=IFCINDEXEDPOLYGONALFACE((362,105,99)); +#870=IFCINDEXEDPOLYGONALFACE((291,314,265)); +#871=IFCINDEXEDPOLYGONALFACE((45,70,18)); +#872=IFCINDEXEDPOLYGONALFACE((210,372,167)); +#873=IFCINDEXEDPOLYGONALFACE((62,63,176)); +#874=IFCINDEXEDPOLYGONALFACE((91,19,35)); +#875=IFCINDEXEDPOLYGONALFACE((206,203,211)); +#876=IFCINDEXEDPOLYGONALFACE((269,260,261)); +#877=IFCINDEXEDPOLYGONALFACE((53,35,129)); +#878=IFCINDEXEDPOLYGONALFACE((54,29,151)); +#879=IFCINDEXEDPOLYGONALFACE((130,368,370)); +#880=IFCINDEXEDPOLYGONALFACE((67,187,189)); +#881=IFCINDEXEDPOLYGONALFACE((371,25,124)); +#882=IFCINDEXEDPOLYGONALFACE((130,209,368)); +#883=IFCINDEXEDPOLYGONALFACE((243,130,370)); +#884=IFCINDEXEDPOLYGONALFACE((213,227,210)); +#885=IFCINDEXEDPOLYGONALFACE((227,372,210)); +#886=IFCINDEXEDPOLYGONALFACE((167,93,151)); +#887=IFCINDEXEDPOLYGONALFACE((372,227,25)); +#888=IFCINDEXEDPOLYGONALFACE((373,29,54)); +#889=IFCINDEXEDPOLYGONALFACE((213,369,227)); +#890=IFCINDEXEDPOLYGONALFACE((371,124,373)); +#891=IFCINDEXEDPOLYGONALFACE((341,348,350,342)); +#892=IFCINDEXEDPOLYGONALFACE((135,66,217)); +#893=IFCINDEXEDPOLYGONALFACE((65,371,33)); +#894=IFCINDEXEDPOLYGONALFACE((350,354,356,351)); +#895=IFCINDEXEDPOLYGONALFACE((333,330,178)); +#896=IFCINDEXEDPOLYGONALFACE((315,254,323)); +#897=IFCINDEXEDPOLYGONALFACE((127,12,30)); +#898=IFCINDEXEDPOLYGONALFACE((100,128,31)); +#899=IFCINDEXEDPOLYGONALFACE((319,5,77)); +#900=IFCINDEXEDPOLYGONALFACE((374,158,2)); +#901=IFCINDEXEDPOLYGONALFACE((375,83,374)); +#902=IFCINDEXEDPOLYGONALFACE((314,274,311)); +#903=IFCINDEXEDPOLYGONALFACE((21,4,52)); +#904=IFCINDEXEDPOLYGONALFACE((288,144,180)); +#905=IFCINDEXEDPOLYGONALFACE((241,137,219)); +#906=IFCINDEXEDPOLYGONALFACE((60,76,45)); +#907=IFCINDEXEDPOLYGONALFACE((10,62,176)); +#908=IFCINDEXEDPOLYGONALFACE((220,147,106)); +#909=IFCINDEXEDPOLYGONALFACE((90,29,373)); +#910=IFCINDEXEDPOLYGONALFACE((70,148,199)); +#911=IFCINDEXEDPOLYGONALFACE((103,37,36)); +#912=IFCINDEXEDPOLYGONALFACE((71,197,306)); +#913=IFCINDEXEDPOLYGONALFACE((117,187,44)); +#914=IFCINDEXEDPOLYGONALFACE((62,44,56)); +#915=IFCINDEXEDPOLYGONALFACE((254,252,244)); +#916=IFCINDEXEDPOLYGONALFACE((59,196,159)); +#917=IFCINDEXEDPOLYGONALFACE((158,141,154)); +#918=IFCINDEXEDPOLYGONALFACE((300,301,241)); +#919=IFCINDEXEDPOLYGONALFACE((23,127,32)); +#920=IFCINDEXEDPOLYGONALFACE((309,303,304)); +#921=IFCINDEXEDPOLYGONALFACE((295,329,304)); +#922=IFCINDEXEDPOLYGONALFACE((369,367,25)); +#923=IFCINDEXEDPOLYGONALFACE((119,102,106)); +#924=IFCINDEXEDPOLYGONALFACE((232,269,318)); +#925=IFCINDEXEDPOLYGONALFACE((208,170,63)); +#926=IFCINDEXEDPOLYGONALFACE((239,97,171)); +#927=IFCINDEXEDPOLYGONALFACE((124,28,213)); +#928=IFCINDEXEDPOLYGONALFACE((268,155,75)); +#929=IFCINDEXEDPOLYGONALFACE((101,270,269)); +#930=IFCINDEXEDPOLYGONALFACE((277,9,7)); +#931=IFCINDEXEDPOLYGONALFACE((320,306,236)); +#932=IFCINDEXEDPOLYGONALFACE((222,193,236)); +#933=IFCINDEXEDPOLYGONALFACE((173,115,50)); +#934=IFCINDEXEDPOLYGONALFACE((299,313,267)); +#935=IFCINDEXEDPOLYGONALFACE((162,207,212)); +#936=IFCINDEXEDPOLYGONALFACE((98,306,197)); +#937=IFCINDEXEDPOLYGONALFACE((295,296,328)); +#938=IFCINDEXEDPOLYGONALFACE((84,81,128)); +#939=IFCINDEXEDPOLYGONALFACE((302,299,297)); +#940=IFCINDEXEDPOLYGONALFACE((212,115,173)); +#941=IFCINDEXEDPOLYGONALFACE((317,323,254)); +#942=IFCINDEXEDPOLYGONALFACE((211,205,204)); +#943=IFCINDEXEDPOLYGONALFACE((39,177,175)); +#944=IFCINDEXEDPOLYGONALFACE((334,263,292)); +#945=IFCINDEXEDPOLYGONALFACE((283,279,286)); +#946=IFCINDEXEDPOLYGONALFACE((129,38,16)); +#947=IFCINDEXEDPOLYGONALFACE((102,349,249)); +#948=IFCINDEXEDPOLYGONALFACE((197,71,12)); +#949=IFCINDEXEDPOLYGONALFACE((330,331,310)); +#950=IFCINDEXEDPOLYGONALFACE((300,308,310)); +#951=IFCINDEXEDPOLYGONALFACE((205,203,190)); +#952=IFCINDEXEDPOLYGONALFACE((133,1,2)); +#953=IFCINDEXEDPOLYGONALFACE((85,206,92)); +#954=IFCINDEXEDPOLYGONALFACE((360,234,39)); +#955=IFCINDEXEDPOLYGONALFACE((122,157,47)); +#956=IFCINDEXEDPOLYGONALFACE((281,280,282)); +#957=IFCINDEXEDPOLYGONALFACE((250,220,249)); +#958=IFCINDEXEDPOLYGONALFACE((58,55,214)); +#959=IFCINDEXEDPOLYGONALFACE((234,360,138)); +#960=IFCINDEXEDPOLYGONALFACE((141,172,298)); +#961=IFCINDEXEDPOLYGONALFACE((27,112,45)); +#962=IFCINDEXEDPOLYGONALFACE((146,182,181)); +#963=IFCINDEXEDPOLYGONALFACE((144,145,181)); +#964=IFCINDEXEDPOLYGONALFACE((195,194,185)); +#965=IFCINDEXEDPOLYGONALFACE((228,221,223)); +#966=IFCINDEXEDPOLYGONALFACE((49,215,358)); +#967=IFCINDEXEDPOLYGONALFACE((74,163,34)); +#968=IFCINDEXEDPOLYGONALFACE((221,72,218)); +#969=IFCINDEXEDPOLYGONALFACE((146,145,107)); +#970=IFCINDEXEDPOLYGONALFACE((194,195,204)); +#971=IFCINDEXEDPOLYGONALFACE((46,123,64)); +#972=IFCINDEXEDPOLYGONALFACE((366,98,86)); +#973=IFCINDEXEDPOLYGONALFACE((48,107,114)); +#974=IFCINDEXEDPOLYGONALFACE((198,104,95)); +#975=IFCINDEXEDPOLYGONALFACE((74,183,132)); +#976=IFCINDEXEDPOLYGONALFACE((21,30,12)); +#977=IFCINDEXEDPOLYGONALFACE((288,150,111)); +#978=IFCINDEXEDPOLYGONALFACE((75,155,225)); +#979=IFCINDEXEDPOLYGONALFACE((166,168,262)); +#980=IFCINDEXEDPOLYGONALFACE((223,119,230)); +#981=IFCINDEXEDPOLYGONALFACE((26,20,92)); +#982=IFCINDEXEDPOLYGONALFACE((219,235,88)); +#983=IFCINDEXEDPOLYGONALFACE((322,264,325)); +#984=IFCINDEXEDPOLYGONALFACE((257,224,220)); +#985=IFCINDEXEDPOLYGONALFACE((289,309,104)); +#986=IFCINDEXEDPOLYGONALFACE((228,146,108)); +#987=IFCINDEXEDPOLYGONALFACE((119,223,218)); +#988=IFCINDEXEDPOLYGONALFACE((367,28,124)); +#989=IFCINDEXEDPOLYGONALFACE((327,324,325)); +#990=IFCINDEXEDPOLYGONALFACE((40,50,115)); +#991=IFCINDEXEDPOLYGONALFACE((321,252,248)); +#992=IFCINDEXEDPOLYGONALFACE((13,86,98)); +#993=IFCINDEXEDPOLYGONALFACE((5,375,374)); +#994=IFCINDEXEDPOLYGONALFACE((135,217,87)); +#995=IFCINDEXEDPOLYGONALFACE((156,147,224)); +#996=IFCINDEXEDPOLYGONALFACE((163,74,63)); +#997=IFCINDEXEDPOLYGONALFACE((56,157,142)); +#998=IFCINDEXEDPOLYGONALFACE((88,190,203)); +#999=IFCINDEXEDPOLYGONALFACE((24,34,163)); +#1000=IFCINDEXEDPOLYGONALFACE((14,202,78)); +#1001=IFCINDEXEDPOLYGONALFACE((248,252,260)); +#1002=IFCINDEXEDPOLYGONALFACE((78,169,26)); +#1003=IFCINDEXEDPOLYGONALFACE((16,134,17)); +#1004=IFCINDEXEDPOLYGONALFACE((161,160,164)); +#1005=IFCINDEXEDPOLYGONALFACE((291,284,287)); +#1006=IFCINDEXEDPOLYGONALFACE((127,30,18)); +#1007=IFCINDEXEDPOLYGONALFACE((182,192,199)); +#1008=IFCINDEXEDPOLYGONALFACE((319,77,71)); +#1009=IFCINDEXEDPOLYGONALFACE((225,69,232)); +#1010=IFCINDEXEDPOLYGONALFACE((302,303,309)); +#1011=IFCINDEXEDPOLYGONALFACE((13,7,36)); +#1012=IFCINDEXEDPOLYGONALFACE((308,22,87)); +#1013=IFCINDEXEDPOLYGONALFACE((262,139,79)); +#1014=IFCINDEXEDPOLYGONALFACE((131,24,73)); +#1015=IFCINDEXEDPOLYGONALFACE((370,369,213)); +#1016=IFCINDEXEDPOLYGONALFACE((92,206,100)); +#1017=IFCINDEXEDPOLYGONALFACE((89,136,233)); +#1018=IFCINDEXEDPOLYGONALFACE((332,316,165)); +#1019=IFCINDEXEDPOLYGONALFACE((189,122,136)); +#1020=IFCINDEXEDPOLYGONALFACE((326,316,332)); +#1021=IFCINDEXEDPOLYGONALFACE((117,142,122)); +#1022=IFCINDEXEDPOLYGONALFACE((132,134,16)); +#1023=IFCINDEXEDPOLYGONALFACE((134,132,183)); +#1024=IFCINDEXEDPOLYGONALFACE((239,238,230)); +#1025=IFCINDEXEDPOLYGONALFACE((180,181,96)); +#1026=IFCINDEXEDPOLYGONALFACE((99,105,113)); +#1027=IFCINDEXEDPOLYGONALFACE((22,61,131)); +#1028=IFCINDEXEDPOLYGONALFACE((321,247,249)); +#1029=IFCINDEXEDPOLYGONALFACE((156,97,120)); +#1030=IFCINDEXEDPOLYGONALFACE((148,96,181)); +#1031=IFCINDEXEDPOLYGONALFACE((152,125,126)); +#1032=IFCINDEXEDPOLYGONALFACE((240,36,37)); +#1033=IFCINDEXEDPOLYGONALFACE((14,68,183)); +#1034=IFCINDEXEDPOLYGONALFACE((293,296,295)); +#1035=IFCINDEXEDPOLYGONALFACE((148,70,112)); +#1036=IFCINDEXEDPOLYGONALFACE((313,299,325)); +#1037=IFCINDEXEDPOLYGONALFACE((154,66,170)); +#1038=IFCINDEXEDPOLYGONALFACE((226,6,123)); +#1039=IFCINDEXEDPOLYGONALFACE((349,347,339)); +#1040=IFCINDEXEDPOLYGONALFACE((318,315,324)); +#1041=IFCINDEXEDPOLYGONALFACE((326,325,299)); +#1042=IFCINDEXEDPOLYGONALFACE((112,27,59)); +#1043=IFCINDEXEDPOLYGONALFACE((262,168,198)); +#1044=IFCINDEXEDPOLYGONALFACE((272,202,51)); +#1045=IFCINDEXEDPOLYGONALFACE((318,269,261)); +#1046=IFCINDEXEDPOLYGONALFACE((167,65,57)); +#1047=IFCINDEXEDPOLYGONALFACE((271,270,266)); +#1048=IFCINDEXEDPOLYGONALFACE((218,72,246)); +#1049=IFCINDEXEDPOLYGONALFACE((94,149,179)); +#1050=IFCINDEXEDPOLYGONALFACE((40,62,153)); +#1051=IFCINDEXEDPOLYGONALFACE((8,84,121)); +#1052=IFCINDEXEDPOLYGONALFACE((32,18,64)); +#1053=IFCINDEXEDPOLYGONALFACE((88,15,109)); +#1054=IFCINDEXEDPOLYGONALFACE((133,31,128)); +#1055=IFCINDEXEDPOLYGONALFACE((193,79,319)); +#1056=IFCINDEXEDPOLYGONALFACE((370,368,367)); +#1057=IFCINDEXEDPOLYGONALFACE((6,103,9)); +#1058=IFCINDEXEDPOLYGONALFACE((214,55,186)); +#1059=IFCINDEXEDPOLYGONALFACE((200,222,75)); +#1060=IFCINDEXEDPOLYGONALFACE((375,319,79)); +#1061=IFCINDEXEDPOLYGONALFACE((95,104,309)); +#1062=IFCINDEXEDPOLYGONALFACE((221,108,49)); +#1063=IFCINDEXEDPOLYGONALFACE((36,240,273)); +#1064=IFCINDEXEDPOLYGONALFACE((69,225,155)); +#1065=IFCINDEXEDPOLYGONALFACE((316,326,302)); +#1066=IFCINDEXEDPOLYGONALFACE((297,294,304)); +#1067=IFCINDEXEDPOLYGONALFACE((195,179,159)); +#1068=IFCINDEXEDPOLYGONALFACE((110,281,186)); +#1069=IFCINDEXEDPOLYGONALFACE((323,322,324)); +#1070=IFCINDEXEDPOLYGONALFACE((172,141,83)); +#1071=IFCINDEXEDPOLYGONALFACE((61,307,219)); +#1072=IFCINDEXEDPOLYGONALFACE((283,284,291)); +#1073=IFCINDEXEDPOLYGONALFACE((184,235,175)); +#1074=IFCINDEXEDPOLYGONALFACE((349,102,246)); +#1075=IFCINDEXEDPOLYGONALFACE((174,165,166)); +#1076=IFCINDEXEDPOLYGONALFACE((48,105,363)); +#1077=IFCINDEXEDPOLYGONALFACE((199,192,237)); +#1078=IFCINDEXEDPOLYGONALFACE((164,172,242)); +#1079=IFCINDEXEDPOLYGONALFACE((217,66,298)); +#1080=IFCINDEXEDPOLYGONALFACE((193,222,200)); +#1081=IFCINDEXEDPOLYGONALFACE((253,168,166)); +#1082=IFCINDEXEDPOLYGONALFACE((202,14,116)); +#1083=IFCINDEXEDPOLYGONALFACE((236,306,366)); +#1084=IFCINDEXEDPOLYGONALFACE((170,66,73)); +#1085=IFCINDEXEDPOLYGONALFACE((360,300,328)); +#1086=IFCINDEXEDPOLYGONALFACE((143,285,110)); +#1087=IFCINDEXEDPOLYGONALFACE((252,254,261)); +#1088=IFCINDEXEDPOLYGONALFACE((131,109,116)); +#1089=IFCINDEXEDPOLYGONALFACE((104,198,168)); +#1090=IFCINDEXEDPOLYGONALFACE((126,58,99)); +#1091=IFCINDEXEDPOLYGONALFACE((47,67,275)); +#1092=IFCINDEXEDPOLYGONALFACE((230,119,120)); +#1093=IFCINDEXEDPOLYGONALFACE((67,259,89)); +#1094=IFCINDEXEDPOLYGONALFACE((257,256,271)); +#1095=IFCINDEXEDPOLYGONALFACE((257,255,231)); +#1096=IFCINDEXEDPOLYGONALFACE((316,289,253)); +#1097=IFCINDEXEDPOLYGONALFACE((17,26,3)); +#1098=IFCINDEXEDPOLYGONALFACE((273,240,171)); +#1099=IFCINDEXEDPOLYGONALFACE((362,99,58)); +#1100=IFCINDEXEDPOLYGONALFACE((48,49,108)); +#1101=IFCINDEXEDPOLYGONALFACE((160,298,172)); +#1102=IFCINDEXEDPOLYGONALFACE((190,88,235)); +#1103=IFCINDEXEDPOLYGONALFACE((258,270,271)); +#1104=IFCINDEXEDPOLYGONALFACE((155,268,366)); +#1105=IFCINDEXEDPOLYGONALFACE((169,272,20)); +#1106=IFCINDEXEDPOLYGONALFACE((312,332,174)); +#1107=IFCINDEXEDPOLYGONALFACE((273,101,43)); +#1108=IFCINDEXEDPOLYGONALFACE((264,322,317)); +#1109=IFCINDEXEDPOLYGONALFACE((287,138,296)); +#1110=IFCINDEXEDPOLYGONALFACE((159,179,149)); +#1111=IFCINDEXEDPOLYGONALFACE((267,313,305)); +#1112=IFCINDEXEDPOLYGONALFACE((126,111,150)); +#1113=IFCINDEXEDPOLYGONALFACE((288,113,114)); +#1114=IFCINDEXEDPOLYGONALFACE((266,270,101)); +#1115=IFCINDEXEDPOLYGONALFACE((123,6,42)); +#1116=IFCINDEXEDPOLYGONALFACE((255,266,171)); +#1117=IFCINDEXEDPOLYGONALFACE((34,24,116)); +#1118=IFCINDEXEDPOLYGONALFACE((91,35,3)); +#1119=IFCINDEXEDPOLYGONALFACE((287,285,143)); +#1120=IFCINDEXEDPOLYGONALFACE((77,4,12)); +#1121=IFCINDEXEDPOLYGONALFACE((95,333,178)); +#1122=IFCINDEXEDPOLYGONALFACE((285,279,280)); +#1123=IFCINDEXEDPOLYGONALFACE((242,83,139)); +#1124=IFCINDEXEDPOLYGONALFACE((332,312,318)); +#1125=IFCINDEXEDPOLYGONALFACE((226,238,239)); +#1126=IFCINDEXEDPOLYGONALFACE((175,235,219)); +#1127=IFCINDEXEDPOLYGONALFACE((177,152,94)); +#1128=IFCINDEXEDPOLYGONALFACE((103,6,226)); +#1129=IFCINDEXEDPOLYGONALFACE((372,25,371)); +#1130=IFCINDEXEDPOLYGONALFACE((101,232,69)); +#1131=IFCINDEXEDPOLYGONALFACE((146,228,192)); +#1132=IFCINDEXEDPOLYGONALFACE((52,4,77)); +#1133=IFCINDEXEDPOLYGONALFACE((133,81,60)); +#1134=IFCINDEXEDPOLYGONALFACE((28,209,243)); +#1135=IFCINDEXEDPOLYGONALFACE((110,58,126)); +#1136=IFCINDEXEDPOLYGONALFACE((140,142,188)); +#1137=IFCINDEXEDPOLYGONALFACE((82,109,131)); +#1138=IFCINDEXEDPOLYGONALFACE((109,15,51)); +#1139=IFCINDEXEDPOLYGONALFACE((210,151,29)); +#1140=IFCINDEXEDPOLYGONALFACE((45,18,30)); +#1141=IFCINDEXEDPOLYGONALFACE((204,195,196)); +#1142=IFCINDEXEDPOLYGONALFACE((229,230,238)); +#1143=IFCINDEXEDPOLYGONALFACE((161,87,217)); +#1144=IFCINDEXEDPOLYGONALFACE((305,313,264)); +#1145=IFCINDEXEDPOLYGONALFACE((84,76,60)); +#1146=IFCINDEXEDPOLYGONALFACE((185,194,190)); +#1147=IFCINDEXEDPOLYGONALFACE((5,1,133)); +#1148=IFCINDEXEDPOLYGONALFACE((226,46,237)); +#1149=IFCINDEXEDPOLYGONALFACE((23,9,277)); +#1150=IFCINDEXEDPOLYGONALFACE((76,84,8)); +#1151=IFCINDEXEDPOLYGONALFACE((294,267,274)); +#1152=IFCINDEXEDPOLYGONALFACE((145,144,114)); +#1153=IFCINDEXEDPOLYGONALFACE((41,15,203)); +#1154=IFCINDEXEDPOLYGONALFACE((13,11,43)); +#1155=IFCINDEXEDPOLYGONALFACE((234,143,125)); +#1156=IFCINDEXEDPOLYGONALFACE((40,16,38)); +#1157=IFCINDEXEDPOLYGONALFACE((272,41,85)); +#1158=IFCINDEXEDPOLYGONALFACE((215,49,48)); +#1159=IFCINDEXEDPOLYGONALFACE((39,137,241)); +#1160=IFCINDEXEDPOLYGONALFACE((311,274,292)); +#1161=IFCINDEXEDPOLYGONALFACE((69,251,86)); +#1162=IFCINDEXEDPOLYGONALFACE((310,87,161)); +#1163=IFCINDEXEDPOLYGONALFACE((248,256,250)); +#1164=IFCINDEXEDPOLYGONALFACE((68,78,17)); +#1165=IFCINDEXEDPOLYGONALFACE((156,231,171)); +#1166=IFCINDEXEDPOLYGONALFACE((59,27,8)); +#1167=IFCINDEXEDPOLYGONALFACE((54,33,371)); +#1168=IFCINDEXEDPOLYGONALFACE((237,192,228)); +#1169=IFCINDEXEDPOLYGONALFACE((362,363,105)); +#1170=IFCINDEXEDPOLYGONALFACE((291,293,314)); +#1171=IFCINDEXEDPOLYGONALFACE((45,112,70)); +#1172=IFCINDEXEDPOLYGONALFACE((62,40,63)); +#1173=IFCINDEXEDPOLYGONALFACE((91,31,19)); +#1174=IFCINDEXEDPOLYGONALFACE((269,270,260)); +#1175=IFCINDEXEDPOLYGONALFACE((53,3,35)); +#1176=IFCINDEXEDPOLYGONALFACE((67,47,187)); +#1177=IFCINDEXEDPOLYGONALFACE((135,73,66)); +#1178=IFCINDEXEDPOLYGONALFACE((333,329,330)); +#1179=IFCINDEXEDPOLYGONALFACE((315,261,254)); +#1180=IFCINDEXEDPOLYGONALFACE((127,23,12)); +#1181=IFCINDEXEDPOLYGONALFACE((100,121,128)); +#1182=IFCINDEXEDPOLYGONALFACE((319,375,5)); +#1183=IFCINDEXEDPOLYGONALFACE((374,83,158)); +#1184=IFCINDEXEDPOLYGONALFACE((375,139,83)); +#1185=IFCINDEXEDPOLYGONALFACE((314,293,274)); +#1186=IFCPOLYGONALFACESET(#472,.F.,(#473,#474,#475,#476,#477,#478,#479,#480,#481,#482,#483,#484,#485,#486,#487,#488,#489,#490,#491,#492,#493,#494,#495,#496,#497,#498,#499,#500,#501,#502,#503,#504,#505,#506,#507,#508,#509,#510,#511,#512,#513,#514,#515,#516,#517,#518,#519,#520,#521,#522,#523,#524,#525,#526,#527,#528,#529,#530,#531,#532,#533,#534,#535,#536,#537,#538,#539,#540,#541,#542,#543,#544,#545,#546,#547,#548,#549,#550,#551,#552,#553,#554,#555,#556,#557,#558,#559,#560,#561,#562,#563,#564,#565,#566,#567,#568,#569,#570,#571,#572,#573,#574,#575,#576,#577,#578,#579,#580,#581,#582,#583,#584,#585,#586,#587,#588,#589,#590,#591,#592,#593,#594,#595,#596,#597,#598,#599,#600,#601,#602,#603,#604,#605,#606,#607,#608,#609,#610,#611,#612,#613,#614,#615,#616,#617,#618,#619,#620,#621,#622,#623,#624,#625,#626,#627,#628,#629,#630,#631,#632,#633,#634,#635,#636,#637,#638,#639,#640,#641,#642,#643,#644,#645,#646,#647,#648,#649,#650,#651,#652,#653,#654,#655,#656,#657,#658,#659,#660,#661,#662,#663,#664,#665,#666,#667,#668,#669,#670,#671,#672,#673,#674,#675,#676,#677,#678,#679,#680,#681,#682,#683,#684,#685,#686,#687,#688,#689,#690,#691,#692,#693,#694,#695,#696,#697,#698,#699,#700,#701,#702,#703,#704,#705,#706,#707,#708,#709,#710,#711,#712,#713,#714,#715,#716,#717,#718,#719,#720,#721,#722,#723,#724,#725,#726,#727,#728,#729,#730,#731,#732,#733,#734,#735,#736,#737,#738,#739,#740,#741,#742,#743,#744,#745,#746,#747,#748,#749,#750,#751,#752,#753,#754,#755,#756,#757,#758,#759,#760,#761,#762,#763,#764,#765,#766,#767,#768,#769,#770,#771,#772,#773,#774,#775,#776,#777,#778,#779,#780,#781,#782,#783,#784,#785,#786,#787,#788,#789,#790,#791,#792,#793,#794,#795,#796,#797,#798,#799,#800,#801,#802,#803,#804,#805,#806,#807,#808,#809,#810,#811,#812,#813,#814,#815,#816,#817,#818,#819,#820,#821,#822,#823,#824,#825,#826,#827,#828,#829,#830,#831,#832,#833,#834,#835,#836,#837,#838,#839,#840,#841,#842,#843,#844,#845,#846,#847,#848,#849,#850,#851,#852,#853,#854,#855,#856,#857,#858,#859,#860,#861,#862,#863,#864,#865,#866,#867,#868,#869,#870,#871,#872,#873,#874,#875,#876,#877,#878,#879,#880,#881,#882,#883,#884,#885,#886,#887,#888,#889,#890,#891,#892,#893,#894,#895,#896,#897,#898,#899,#900,#901,#902,#903,#904,#905,#906,#907,#908,#909,#910,#911,#912,#913,#914,#915,#916,#917,#918,#919,#920,#921,#922,#923,#924,#925,#926,#927,#928,#929,#930,#931,#932,#933,#934,#935,#936,#937,#938,#939,#940,#941,#942,#943,#944,#945,#946,#947,#948,#949,#950,#951,#952,#953,#954,#955,#956,#957,#958,#959,#960,#961,#962,#963,#964,#965,#966,#967,#968,#969,#970,#971,#972,#973,#974,#975,#976,#977,#978,#979,#980,#981,#982,#983,#984,#985,#986,#987,#988,#989,#990,#991,#992,#993,#994,#995,#996,#997,#998,#999,#1000,#1001,#1002,#1003,#1004,#1005,#1006,#1007,#1008,#1009,#1010,#1011,#1012,#1013,#1014,#1015,#1016,#1017,#1018,#1019,#1020,#1021,#1022,#1023,#1024,#1025,#1026,#1027,#1028,#1029,#1030,#1031,#1032,#1033,#1034,#1035,#1036,#1037,#1038,#1039,#1040,#1041,#1042,#1043,#1044,#1045,#1046,#1047,#1048,#1049,#1050,#1051,#1052,#1053,#1054,#1055,#1056,#1057,#1058,#1059,#1060,#1061,#1062,#1063,#1064,#1065,#1066,#1067,#1068,#1069,#1070,#1071,#1072,#1073,#1074,#1075,#1076,#1077,#1078,#1079,#1080,#1081,#1082,#1083,#1084,#1085,#1086,#1087,#1088,#1089,#1090,#1091,#1092,#1093,#1094,#1095,#1096,#1097,#1098,#1099,#1100,#1101,#1102,#1103,#1104,#1105,#1106,#1107,#1108,#1109,#1110,#1111,#1112,#1113,#1114,#1115,#1116,#1117,#1118,#1119,#1120,#1121,#1122,#1123,#1124,#1125,#1126,#1127,#1128,#1129,#1130,#1131,#1132,#1133,#1134,#1135,#1136,#1137,#1138,#1139,#1140,#1141,#1142,#1143,#1144,#1145,#1146,#1147,#1148,#1149,#1150,#1151,#1152,#1153,#1154,#1155,#1156,#1157,#1158,#1159,#1160,#1161,#1162,#1163,#1164,#1165,#1166,#1167,#1168,#1169,#1170,#1171,#1172,#1173,#1174,#1175,#1176,#1177,#1178,#1179,#1180,#1181,#1182,#1183,#1184,#1185),$); +#1187=IFCSHAPEREPRESENTATION(#11,'Body','Tessellation',(#1186)); +#1188=IFCREPRESENTATIONMAP(#465,#1187); +#1189=IFCCARTESIANPOINT((0.,0.,0.)); +#1190=IFCDIRECTION((0.,0.,1.)); +#1191=IFCDIRECTION((1.,0.,0.)); +#1192=IFCAXIS2PLACEMENT3D(#1189,#1190,#1191); +#1198=IFCCARTESIANPOINTLIST2D(((-161.386370658875,0.390071421861649),(-162.97847032547,30.6398719549179),(-152.914509177208,57.6198659837246),(-148.716494441032,79.5774236321449),(-149.392008781433,102.066904306412),(-151.44681930542,125.798091292381),(-157.580137252808,132.616892457008),(-169.090509414673,130.419373512268),(-180.844187736511,118.465758860111),(-182.052731513977,90.6300097703934),(-183.831930160522,55.2833341062069),(-183.684945106506,39.6271869540215),(-192.724362015724,-4.67484071850777))); +#1199=IFCINDEXEDPOLYCURVE(#1198,$,$); +#1200=IFCCARTESIANPOINTLIST2D(((-173.348978161812,20.3548446297646),(-163.15957903862,61.7493018507957),(-157.428041100502,97.4122136831284),(-165.070101618767,119.064696133137))); +#1201=IFCINDEXEDPOLYCURVE(#1200,$,$); +#1202=IFCCARTESIANPOINTLIST2D(((-160.456106066704,37.40194439888),(-130.220890045166,47.9081235826015),(-97.7480411529541,54.0151223540306),(-74.405312538147,73.5662579536438),(-37.3027324676514,89.5451977849007),(-5.24431467056274,85.4801684617996),(44.9999570846558,68.9153224229813),(76.8988728523254,42.0413166284561),(100.000023841858,20.0000032782555),(112.531423568726,-13.4119689464569),(110.93932390213,-41.5389761328697),(101.917445659637,-74.4422599673271),(128.680348396301,-63.8554915785789),(138.488471508026,-43.2419404387474),(134.837985038757,-13.5693177580833),(123.972177505493,5.19884377717972),(100.000023841858,20.0000032782555))); +#1203=IFCINDEXEDPOLYCURVE(#1202,$,$); +#1204=IFCCARTESIANPOINTLIST2D(((-41.3289070129395,60.5994611978531),(-55.4808378219604,46.8897596001625),(-78.0355930328369,36.7180481553078),(-99.2635488510132,18.5858532786369),(-136.412382125854,4.43390011787415))); +#1205=IFCINDEXEDPOLYCURVE(#1204,$,$); +#1206=IFCCARTESIANPOINTLIST2D(((-143.91028881073,8.47188383340836),(-127.020835876465,23.3357548713684),(-99.5742082595825,49.370177090168),(-68.5850381851196,68.8069462776184),(-29.6431183815002,76.3391554355621),(-26.7347097396851,71.21342420578),(-33.8107347488403,58.3882182836533),(-58.5765838623047,19.0281048417091),(-103.685975074768,-7.94906169176102),(-130.663156509399,-14.5827829837799))); +#1207=IFCINDEXEDPOLYCURVE(#1206,$,$); +#1208=IFCCARTESIANPOINTLIST2D(((101.917445659637,-74.4422599673271),(77.6327848434448,-98.9715680480003),(43.5214042663574,-123.003117740154),(-1.87504291534424,-136.098772287369),(-44.7412729263306,-130.966305732727),(-75.5681991577148,-105.624251067638),(-114.447318017483,-103.237792849541),(-148.344993591309,-102.713964879513),(-129.387378692627,-83.7726220488548),(-112.089991569519,-52.3208752274513))); +#1209=IFCINDEXEDPOLYCURVE(#1208,$,$); +#1210=IFCCARTESIANPOINTLIST2D(((-148.344993591309,-102.713964879513),(-160.57014465332,-110.72414368391),(-187.541648745537,-117.346309125423),(-205.768346786499,-106.695257127285),(-214.284062385559,-90.5132815241814),(-222.012758255005,-43.5851588845253),(-217.635273933411,-23.6888602375984),(-189.349979162216,11.8629187345505))); +#1211=IFCINDEXEDPOLYCURVE(#1210,$,$); +#1212=IFCGEOMETRICCURVESET((#1199,#1201,#1203,#1205,#1207,#1209,#1211)); +#1213=IFCSHAPEREPRESENTATION(#24,'Body','Annotation2D',(#1212)); +#1214=IFCREPRESENTATIONMAP(#1192,#1213); +#1215=IFCFURNITURETYPE('02XxQ_3oT0SPrmFPATrt7o',$,'BUN01',$,$,$,(#1188,#1214),$,$,.NOTDEFINED.,.NOTDEFINED.); +ENDSEC; +END-ISO-10303-21; diff --git a/src/bonsai/test/modal/test_modal.py b/src/bonsai/test/modal/test_modal.py new file mode 100644 index 0000000000..1ea6b1dbd5 --- /dev/null +++ b/src/bonsai/test/modal/test_modal.py @@ -0,0 +1,451 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Bruno Perdigão +# +# 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 . + + +import inspect +import os +import sys +import time + +import bpy +import ifcopenshell +import pytest + +from bonsai import tool as tool +from bonsai.bim.ifc import IfcStore +from bonsai.bim.module.model.data import AuthoringData as Model + +GREEN = "\033[32m" +RED = "\033[31m" +RESET = "\033[0m" + + +def _assert_pass(message: str) -> None: + caller_name = inspect.stack()[1].function + print(f"{GREEN}{caller_name} PASSED: {message}{RESET}") + + +def _handle_error(e: Exception, on_done) -> None: + print(f"{RED}Assertion failed: {e}{RESET}") + if on_done: + on_done() + + +def run_iter_from_timer(event_iter, on_complete=None, on_error=None): + i = iter(event_iter) + done = False + + def event_step(): + nonlocal done, on_complete + try: + ret = next(i, "STOP") + if ret in (None, "STOP", "FINISHED"): + done = True + if on_complete: + on_complete() + return None + except StopIteration: + done = True + if on_complete: + on_complete() + return None + except Exception as e: + done = True + print(f"Exception: {e}") + if on_error: + on_error(e) + elif on_complete: + on_complete() + return None + return 0.0 + + bpy.app.timers.register(event_step, first_interval=0.0) + + +def preset_event_simulate(window, event_type, value, x, y): + if value == "TAP": + yield window.event_simulate(event_type, "PRESS", x=x, y=y) + yield window.event_simulate(event_type, "RELEASE", x=x, y=y) + else: + yield window.event_simulate(event_type, value, x=x, y=y) + + +def cleanup(): + bpy.app.use_event_simulate = False + bpy.ops.wm.quit_blender() + + +def _get_valid_window() -> bpy.types.Window: + win = bpy.context.window + if win is not None: + return win + wm = getattr(bpy.context, "window_manager", None) + if wm and wm.windows: + return wm.windows[0] + raise RuntimeError("Unable to locate a Blender UI window.") + + +def new_project(): + IfcStore.purge() + bpy.ops.wm.read_homefile(app_template="", use_factory_startup=True) + if len(bpy.data.objects) > 0: + bpy.data.batch_remove(bpy.data.objects) + bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) + if len(bpy.data.materials) > 0: + bpy.data.batch_remove(bpy.data.materials) + bpy.context.scene.unit_settings.system = "METRIC" + bpy.context.scene.unit_settings.length_unit = "MILLIMETERS" + props = tool.Project.get_project_props() + props.template_file = "0" + tool.Blender.get_addon_preferences().should_play_chaching_sound = False + + +def get_area_and_region(window): + area = next(area for area in window.screen.areas if area.type == "VIEW_3D") + region = next(region for region in area.regions if region.type == "WINDOW") + return area, region + + +def test_snap_object_detection(window): + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", 0, 0) + area, region = get_area_and_region(window) + x = round(area.width * 0.5 + area.x) + y = round(area.height * 0.54 + area.y) + + yield from preset_event_simulate(window, "ESC", "TAP", x, y) + + measure_settings = tool.Project.get_measure_tool_settings() + measure_settings.measurement_type = "POLYLINE" + for obj in tool.Blender.get_selected_objects(): + obj.select_set(False) + with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]): + bpy.ops.bim.measure_tool("INVOKE_DEFAULT", measure_type="POLYLINE") + + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y) + yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y) + snap_point = tool.Model.get_polyline_props().snap_mouse_point[0] + assert_msg = "First click should have a snap_object" + assert snap_point.snap_object, assert_msg + _assert_pass(assert_msg) + assert_msg = "snap_object should be a string with the object name" + assert type(snap_point.snap_object) == str, assert_msg + _assert_pass(assert_msg) + assert_msg = "Object should be an IfcWall" + assert snap_point.snap_object.split("/")[0] == "IfcWall", assert_msg + _assert_pass(assert_msg) + + offset = 200 + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x - offset, y) + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x - offset, y) + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x - offset, y) + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x - offset, y) + yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x - offset, y) + snap_point = tool.Model.get_polyline_props().snap_mouse_point[0] + assert_msg = "Second click should not have a snap_object" + assert not snap_point.snap_object, assert_msg + _assert_pass(assert_msg) + + yield from preset_event_simulate(window, "RET", "TAP", x, y) + yield "FINISHED" + + +def test_snap_partially_behind_camera(window): + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", 0, 0) + area, region = get_area_and_region(window) + x = round(area.width * 0.20 + area.x) + y = round(area.height * 0.15 + area.y) + + yield from preset_event_simulate(window, "ESC", "TAP", x, y) + + measure_settings = tool.Project.get_measure_tool_settings() + measure_settings.measurement_type = "POLYLINE" + for obj in tool.Blender.get_selected_objects(): + obj.select_set(False) + with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]): + bpy.ops.bim.measure_tool("INVOKE_DEFAULT", measure_type="POLYLINE") + + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y) + yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y) + snap_point = tool.Model.get_polyline_props().snap_mouse_point[0] + assert_msg = "First click should have a snap_object" + assert snap_point.snap_object, assert_msg + _assert_pass(assert_msg) + assert_msg = "snap_object should be a string with the object name" + assert type(snap_point.snap_object) == str, assert_msg + _assert_pass(assert_msg) + assert_msg = "snap_type should be 'Edge'" + assert snap_point.snap_type == "Edge", assert_msg + _assert_pass(assert_msg) + assert_msg = "Object should be an IfcSlab" + assert snap_point.snap_object.split("/")[0] == "IfcSlab", assert_msg + _assert_pass(assert_msg) + + offset = 200 + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y) + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y) + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y) + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y) + yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x - offset, y) + snap_point = tool.Model.get_polyline_props().snap_mouse_point[0] + assert_msg = "Second click should have a snap_object" + assert snap_point.snap_object, assert_msg + _assert_pass(assert_msg) + assert_msg = "snap_object should be a string with the object name" + assert type(snap_point.snap_object) == str, assert_msg + _assert_pass(assert_msg) + assert_msg = "snap_type should be 'Face'" + assert snap_point.snap_type == "Face", assert_msg + _assert_pass(assert_msg) + assert_msg = "Object should be an IfcSlab" + assert snap_point.snap_object.split("/")[0] == "IfcSlab", assert_msg + _assert_pass(assert_msg) + + yield from preset_event_simulate(window, "RET", "TAP", x, y) + yield "FINISHED" + + +def test_snap_in_xray_mode(window): + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", 0, 0) + area, region = get_area_and_region(window) + x = round(area.width * 0.68 + area.x) + y = round(area.height * 0.54 + area.y) + + area.spaces[0].shading.show_xray = True + + yield from preset_event_simulate(window, "ESC", "TAP", x, y) + + measure_settings = tool.Project.get_measure_tool_settings() + measure_settings.measurement_type = "POLYLINE" + for obj in tool.Blender.get_selected_objects(): + obj.select_set(False) + with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]): + bpy.ops.bim.measure_tool("INVOKE_DEFAULT", measure_type="POLYLINE") + + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y) + yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y) + snap_point = tool.Model.get_polyline_props().snap_mouse_point[0] + assert_msg = "First click should have a snap_object" + assert snap_point.snap_object, assert_msg + _assert_pass(assert_msg) + assert_msg = "snap_object should be a string with the object name" + assert type(snap_point.snap_object) == str, assert_msg + _assert_pass(assert_msg) + assert_msg = "Object should be an IfcFurniture" + assert snap_point.snap_object.split("/")[0] == "IfcFurniture", assert_msg + _assert_pass(assert_msg) + + yield from preset_event_simulate(window, "RET", "TAP", x, y) + yield "FINISHED" + + +def test_snap_far_from_origin(window): + bpy.context.view_layer.objects.active = None + bpy.ops.object.select_all(action="DESELECT") + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", 0, 0) + area, region = get_area_and_region(window) + x = round(area.width * 0.155 + area.x) + y = round(area.height * 0.18 + area.y) + + yield from preset_event_simulate(window, "ESC", "TAP", x, y) + + bpy.data.objects["IfcBuildingElementProxy/Cube"].select_set(True) + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y) + with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]): + bpy.ops.view3d.view_selected() + + measure_settings = tool.Project.get_measure_tool_settings() + measure_settings.measurement_type = "POLYLINE" + for obj in tool.Blender.get_selected_objects(): + obj.select_set(False) + with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]): + bpy.ops.bim.measure_tool("INVOKE_DEFAULT", measure_type="POLYLINE") + + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y) + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y) + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y) + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y) + yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y) + snap_point = tool.Model.get_polyline_props().snap_mouse_point[0] + assert_msg = "First click should have a snap_object" + assert snap_point.snap_object, assert_msg + _assert_pass(assert_msg) + assert_msg = "snap_object should be a string with the object name" + assert type(snap_point.snap_object) == str, assert_msg + _assert_pass(assert_msg) + assert_msg = "snap_type should be 'Vertex'" + assert snap_point.snap_type == "Vertex", assert_msg + _assert_pass(assert_msg) + assert_msg = "x should be 1000000" + assert round(snap_point.x, 3) == 1000.0, assert_msg + _assert_pass(assert_msg) + assert_msg = "y should be 1000000" + assert round(snap_point.y, 3) == 1000.0, assert_msg + _assert_pass(assert_msg) + + yield from preset_event_simulate(window, "RET", "TAP", x, y) + yield "FINISHED" + + +def test_snap_targets(window): + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", 0, 0) + area, region = get_area_and_region(window) + x = round(area.width * 0.44 + area.x) + y = round(area.height * 0.73 + area.y) + + yield from preset_event_simulate(window, "ESC", "TAP", x, y) + + options = [] + props = tool.Snap.get_snap_props() + try: + annotations = props.__annotations__ + except AttributeError: + annotations = type(props).__annotations__ + for prop in annotations.keys(): + if getattr(props, prop): + options.append((prop, props.rna_type.properties[prop].name)) + + for prop, name in options: + any(setattr(props, prop2, prop2 == prop) for prop2, _ in options) # set prop to true and others to false + measure_settings = tool.Project.get_measure_tool_settings() + measure_settings.measurement_type = "POLYLINE" + for obj in tool.Blender.get_selected_objects(): + obj.select_set(False) + with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]): + bpy.ops.bim.measure_tool("INVOKE_DEFAULT", measure_type="POLYLINE") + snap_types = [] + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y) + yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y) + snap_type = tool.Model.get_polyline_props().snap_mouse_point[0].snap_type + snap_types.append(snap_type) + + new_x = x + 200 + new_y = y - 55 + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", new_x, new_y) + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", new_x, new_y) + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", new_x, new_y) + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", new_x, new_y) + yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y) + snap_type = tool.Model.get_polyline_props().snap_mouse_point[0].snap_type + snap_types.append(snap_type) + + new_x = x + 130 + new_y = y - 358 + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", new_x, new_y) + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", new_x, new_y) + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", new_x, new_y) + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", new_x, new_y) + yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y) + snap_type = tool.Model.get_polyline_props().snap_mouse_point[0].snap_type + snap_types.append(snap_type) + + yield from preset_event_simulate(window, "ESC", "TAP", x, y) + assert_msg = f"{name} should be in snap_types: {snap_types}" + assert name in snap_types + _assert_pass(assert_msg) + + +def test_draw_polyline_wall(window, x, y): + yield from preset_event_simulate(window, "ESC", "TAP", x, y) + area, region = get_area_and_region(window) + + for obj in tool.Blender.get_selected_objects(): + obj.select_set(False) + with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]): + props = tool.Model.get_model_props() + ifc = tool.Ifc.get() + relating_type = ifc.by_type("IfcWallType")[0] + + if tool.Model.get_usage_type(relating_type) == "LAYER2": + props.ifc_class = "IfcWallType" + props.relating_type_id = str(relating_type.id()) + + bpy.ops.bim.draw_polyline_wall("INVOKE_DEFAULT") + + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y) + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y) + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y) + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y) + yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y) + yield from preset_event_simulate(window, "X", "TAP", x, y) + + offset = 200 + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y) + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y) + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y) + yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x + offset, y) + yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x + offset, y) + + yield from preset_event_simulate(window, "RET", "TAP", x, y) + element = tool.Ifc.get_entity(bpy.context.selected_objects[0]) + + assert_msg = "Created object should be IfcWall" + assert element.is_a() == "IfcWall" + _assert_pass(assert_msg) + assert_msg = "Created object should be typed by IfcWallType" + assert ifcopenshell.util.element.get_type(element).is_a() == "IfcWallType" + _assert_pass(assert_msg) + # TODO Asset the axis has the same X value + + yield "FINISHED" + + +def run_tests(): + module_name = os.getenv("MODULE", "snap") + if module_name == "wall": + filepath = f"./test/files/wall.ifc" + bpy.ops.bim.load_project(filepath=filepath) + window = _get_valid_window() + test_queue = [lambda w=window: test_draw_polyline_wall(w, 960, 540)] + elif module_name == "snap": + filepath = f"./test/files/snap.ifc" + bpy.ops.bim.load_project(filepath=filepath) + window = _get_valid_window() + test_queue = [ + lambda w=window: test_snap_object_detection(w), + lambda w=window: test_snap_partially_behind_camera(w), + lambda w=window: test_snap_in_xray_mode(w), + lambda w=window: test_snap_far_from_origin(w), + ] + elif module_name == "snap-target": + filepath = f"./test/files/snap-target.ifc" + bpy.ops.bim.load_project(filepath=filepath) + window = _get_valid_window() + test_queue = [ + lambda w=window: test_snap_targets(w), + ] + else: + cleanup() + + def _next(): + if not test_queue: + cleanup() + return + test_fn = test_queue.pop(0) + # use the shared timer infrastructure + run_iter_from_timer( + test_fn(), + on_complete=_next, + on_error=lambda e: _handle_error(e, _next), + ) + + _next() + + +if __name__ == "__main__": + new_project() + run_tests() diff --git a/src/bonsai/test/tool/test_blender.py b/src/bonsai/test/tool/test_blender.py index cb5d06bc71..7a2f8017d3 100644 --- a/src/bonsai/test/tool/test_blender.py +++ b/src/bonsai/test/tool/test_blender.py @@ -15,6 +15,8 @@ # # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +# +# This file was modified with the assistance of an AI coding tool. import tempfile from pathlib import Path @@ -22,6 +24,7 @@ from typing import TYPE_CHECKING import bpy import ifcopenshell +import numpy as np import pytest import bonsai @@ -167,3 +170,16 @@ class TestGetDebugInfo(NewFile): def test_failed_to_load_returns_only_base_keys(self): info = bonsai.get_debug_info(bonsai_failed_to_load=True) assert set(info.keys()) == self.EXPECTED_KEYS + + +class TestNpFrombufferLegacy(NewFile): + """Decoding ``n`` floats from a buffer must yield a length-``n`` array + regardless of whether the buffer was written as ``float32`` or ``float64``.""" + + @pytest.mark.parametrize("n", [3, 9]) + @pytest.mark.parametrize("dtype", [np.float32, np.float64]) + def test_decodes_to_n_elements(self, n, dtype): + data = np.arange(n, dtype=dtype).tobytes() + result = subject.np_frombuffer_legacy(data, n) + assert result.shape == (n,) + np.testing.assert_allclose(result, np.arange(n)) diff --git a/src/bonsai/test/tool/test_blender_any_array_child_cache.py b/src/bonsai/test/tool/test_blender_any_array_child_cache.py new file mode 100644 index 0000000000..1021afbb4d --- /dev/null +++ b/src/bonsai/test/tool/test_blender_any_array_child_cache.py @@ -0,0 +1,152 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Cache-invalidation tests for ``tool.Blender.Modifier.any_selected_is_array_child``. + +The wall-topology gizmo gate calls this on every viewport input event. The +underlying ``is_array_child`` check is a BBIM_Array pset lookup per selected +object; without memoisation that runs N_selected times per event. These +tests pin that the cache reuses results across identical (selection, IFC +generation) pairs and invalidates on either change.""" + +from unittest.mock import Mock, patch + +import pytest + +pytestmark = pytest.mark.model + + +@pytest.fixture(autouse=True) +def _reset_memo(): + from bonsai import tool + + saved = getattr(tool.Blender.Modifier, "_any_selected_array_child_memo", None) + tool.Blender.Modifier._any_selected_array_child_memo = None + yield + tool.Blender.Modifier._any_selected_array_child_memo = saved + + +def _mock_obj(name: str) -> Mock: + obj = Mock() + obj.name = name + return obj + + +def test_repeat_call_within_generation_reuses_cache(): + from bonsai import tool + + obj_a = _mock_obj("Wall.001") + obj_b = _mock_obj("Wall.002") + + is_array_child_calls = {"n": 0} + + def counting_is_array_child(elem): + is_array_child_calls["n"] += 1 + return False + + with patch("bonsai.tool.blender.tool.Blender.get_selected_objects", return_value=[obj_a, obj_b]), patch( + "bonsai.tool.blender.tool.Parametric.get_geom_generation", return_value=5 + ), patch("bonsai.tool.blender.tool.Ifc.get_entity", return_value=Mock()), patch.object( + tool.Blender.Modifier, "is_array_child", side_effect=counting_is_array_child + ): + first = tool.Blender.Modifier.any_selected_is_array_child() + second = tool.Blender.Modifier.any_selected_is_array_child() + + assert first is False + assert second is False + assert is_array_child_calls["n"] == 2, "First call walks N_selected; second call must reuse cached result" + + +def test_generation_advance_invalidates_cache(): + from bonsai import tool + + obj = _mock_obj("Wall.001") + gen_state = {"gen": 1} + + call_count = {"n": 0} + + def counting_is_array_child(elem): + call_count["n"] += 1 + return False + + with patch("bonsai.tool.blender.tool.Blender.get_selected_objects", return_value=[obj]), patch( + "bonsai.tool.blender.tool.Parametric.get_geom_generation", side_effect=lambda: gen_state["gen"] + ), patch("bonsai.tool.blender.tool.Ifc.get_entity", return_value=Mock()), patch.object( + tool.Blender.Modifier, "is_array_child", side_effect=counting_is_array_child + ): + tool.Blender.Modifier.any_selected_is_array_child() + first = call_count["n"] + gen_state["gen"] = 2 + tool.Blender.Modifier.any_selected_is_array_child() + + assert call_count["n"] > first + + +def test_selection_change_invalidates_cache(): + from bonsai import tool + + obj_a = _mock_obj("Wall.001") + obj_b = _mock_obj("Wall.002") + selection = {"sel": [obj_a]} + + call_count = {"n": 0} + + def counting_is_array_child(elem): + call_count["n"] += 1 + return False + + with patch("bonsai.tool.blender.tool.Blender.get_selected_objects", side_effect=lambda: selection["sel"]), patch( + "bonsai.tool.blender.tool.Parametric.get_geom_generation", return_value=1 + ), patch("bonsai.tool.blender.tool.Ifc.get_entity", return_value=Mock()), patch.object( + tool.Blender.Modifier, "is_array_child", side_effect=counting_is_array_child + ): + tool.Blender.Modifier.any_selected_is_array_child() + first = call_count["n"] + selection["sel"] = [obj_a, obj_b] + tool.Blender.Modifier.any_selected_is_array_child() + + assert call_count["n"] > first + + +def test_short_circuits_on_first_hit(): + """``is_array_child`` returning True for the first selected object must + short-circuit; the rest of the selection isn't walked. Belt-and-suspenders + test — the early-return existed before the cache wrap and must survive it.""" + from bonsai import tool + + obj_a = _mock_obj("Wall.001") + obj_b = _mock_obj("Wall.002") + obj_c = _mock_obj("Wall.003") + + call_count = {"n": 0} + + def counting_is_array_child(elem): + call_count["n"] += 1 + return True + + with patch("bonsai.tool.blender.tool.Blender.get_selected_objects", return_value=[obj_a, obj_b, obj_c]), patch( + "bonsai.tool.blender.tool.Parametric.get_geom_generation", return_value=1 + ), patch("bonsai.tool.blender.tool.Ifc.get_entity", return_value=Mock()), patch.object( + tool.Blender.Modifier, "is_array_child", side_effect=counting_is_array_child + ): + result = tool.Blender.Modifier.any_selected_is_array_child() + + assert result is True + assert call_count["n"] == 1 diff --git a/src/bonsai/test/tool/test_blender_dashed_line.py b/src/bonsai/test/tool/test_blender_dashed_line.py new file mode 100644 index 0000000000..29694dd367 --- /dev/null +++ b/src/bonsai/test/tool/test_blender_dashed_line.py @@ -0,0 +1,107 @@ +# 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 . +# +# This file was generated with the assistance of an AI coding tool. + +"""Tests for the world-space dashed-line segmentation helper in tool.Blender. + +The helper slices each input edge into world-space dash chunks so callers can +build a vanilla LINES batch (any shader, including ``POLYLINE_UNIFORM_COLOR``) +that renders as dashes. Sharing the front-pass shader for the occluded back +pass is what keeps depth values coherent between the visible / occluded +outlines — a custom dashed shader against a builtin solid shader produces +inter-pass z-fighting and the wrong portion of the outline ends up dashed.""" + +import math +import types + +import bpy +import pytest + +import bonsai.tool as tool + +pytestmark = pytest.mark.model + + +@pytest.fixture(autouse=True) +def _require_real_bpy(): + if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"): + pytest.skip("requires real Blender (bpy is mocked or absent)") + + +class TestBuildDashedLineSegments: + def test_unit_edge_produces_expected_dash_count(self): + verts, edges = tool.Blender.build_dashed_line_segments( + [(0.0, 0.0, 0.0), (1.0, 0.0, 0.0)], + [(0, 1)], + dash_period=0.20, + dash_width=0.10, + ) + assert len(edges) == 5 + assert len(verts) == 10 + + def test_each_dash_runs_dash_width_along_the_edge(self): + verts, edges = tool.Blender.build_dashed_line_segments( + [(0.0, 0.0, 0.0), (1.0, 0.0, 0.0)], + [(0, 1)], + dash_period=0.20, + dash_width=0.10, + ) + for i, j in edges: + dx = verts[j][0] - verts[i][0] + assert math.isclose(dx, 0.10, abs_tol=1e-9) + + def test_dash_phase_resets_per_input_edge(self): + verts, edges = tool.Blender.build_dashed_line_segments( + [(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (1.0, 0.0, 0.0), (1.0, 1.0, 0.0)], + [(0, 1), (2, 3)], + dash_period=0.20, + dash_width=0.10, + ) + first_dash_start = verts[edges[0][0]] + second_edge_first_dash_start = verts[edges[5][0]] + assert math.isclose(first_dash_start[0], 0.0, abs_tol=1e-9) + assert math.isclose(second_edge_first_dash_start[1], 0.0, abs_tol=1e-9) + + def test_trailing_partial_dash_is_clamped_to_edge_end(self): + verts, edges = tool.Blender.build_dashed_line_segments( + [(0.0, 0.0, 0.0), (0.25, 0.0, 0.0)], + [(0, 1)], + dash_period=0.20, + dash_width=0.10, + ) + last_x = verts[edges[-1][1]][0] + assert last_x <= 0.25 + 1e-9 + + def test_zero_length_edge_emits_no_dashes(self): + verts, edges = tool.Blender.build_dashed_line_segments( + [(0.0, 0.0, 0.0), (0.0, 0.0, 0.0)], + [(0, 1)], + dash_period=0.20, + dash_width=0.10, + ) + assert verts == [] + assert edges == [] + + def test_invalid_dash_parameters_return_empty(self): + assert tool.Blender.build_dashed_line_segments( + [(0.0, 0.0, 0.0), (1.0, 0.0, 0.0)], [(0, 1)], dash_period=0.0, dash_width=0.10 + ) == ([], []) + assert tool.Blender.build_dashed_line_segments( + [(0.0, 0.0, 0.0), (1.0, 0.0, 0.0)], [(0, 1)], dash_period=0.20, dash_width=-0.10 + ) == ([], []) diff --git a/src/bonsai/test/tool/test_cost.py b/src/bonsai/test/tool/test_cost.py new file mode 100644 index 0000000000..564337a8d4 --- /dev/null +++ b/src/bonsai/test/tool/test_cost.py @@ -0,0 +1,45 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2021 Dion Moult +# +# 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 . + + +import ifcopenshell.api.cost + +import bonsai.core.tool +import bonsai.tool as tool +import test.bim.bootstrap +from bonsai.tool.cost import Cost as subject +from test.bim.bootstrap import NewFile + + +class TestImplementsTool(NewFile): + def test_run(self): + assert isinstance(subject(), bonsai.core.tool.Cost) + + +class TestDisableEditingCostItemParent(NewFile): + def test_avoid_recursion_error(newfile, monkeypatch): + class DummyProps: + def __init__(self): + self.change_cost_item_parent = None + self.active_cost_item_id = 5 + + props = DummyProps() + monkeypatch.setattr("bonsai.tool.Cost.get_cost_props", lambda: props) + subject.disable_editing_cost_item_parent() + assert props.active_cost_item_id == 0 + assert props.change_cost_item_parent is not False diff --git a/src/bonsai/test/tool/test_model.py b/src/bonsai/test/tool/test_model.py index 30782b8a15..fd9e3dfad8 100644 --- a/src/bonsai/test/tool/test_model.py +++ b/src/bonsai/test/tool/test_model.py @@ -588,6 +588,10 @@ class TestGenerateStair2DProfile(NewFile): class TestUsingArrays(NewFile): + @staticmethod + def _array_objects() -> list[bpy.types.Object]: + return [o for o in bpy.data.objects if (e := tool.Ifc.get_entity(o)) and e.is_a("IfcActuator")] + def setup_array(self, add_second_layer=False, sync_children=False): tool.Project.get_project_props().template_file = "0" bpy.ops.bim.create_project() @@ -605,7 +609,7 @@ class TestUsingArrays(NewFile): props.count = 4 props.x = 4 props.sync_children = sync_children - bpy.ops.bim.edit_array(item=0) + bpy.ops.bim.finish_editing_array() if add_second_layer: bpy.ops.bim.add_array() @@ -614,14 +618,14 @@ class TestUsingArrays(NewFile): props.count = 3 props.y = 4 props.sync_children = sync_children - bpy.ops.bim.edit_array(item=1) + bpy.ops.bim.finish_editing_array() def test_remove_array_last_to_first(self): self.setup_array(add_second_layer=True) bpy.ops.bim.remove_array(item=1) - assert len(bpy.context.selected_objects) == 4 + assert len(self._array_objects()) == 4 bpy.ops.bim.remove_array(item=0) - assert len(bpy.context.selected_objects) == 1 + assert len(self._array_objects()) == 1 def test_remove_array_first_to_last(self): self.setup_array(add_second_layer=True) @@ -647,7 +651,7 @@ class TestUsingArrays(NewFile): bpy.ops.bim.apply_array() # apply second layer bpy.ops.bim.apply_array() # apply first layer - objs = bpy.context.selected_objects + objs = self._array_objects() assert len(objs) == 12 # check BBIM_Array psets are removed @@ -930,3 +934,88 @@ class TestOffsetWall(NewFile): usage.DirectionSense = "NEGATIVE" subject.offset_wall(obj, "EXTERIOR") assert usage.OffsetFromReferenceLine == 100 + + +class TestGetSiblingOccurrenceCount(NewFile): + """The pen-icon dispatcher's pre-edit warning depends on this count: zero + means the edit is safe (unique geometry), non-zero means the edit will + silently mutate other instances sharing the same resolved body rep.""" + + def _make_body_subcontext(self, ifc: ifcopenshell.file) -> ifcopenshell.entity_instance: + import ifcopenshell.api.context + + ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject", name="Project") + parent = ifcopenshell.api.context.add_context(ifc, context_type="Model") + return ifcopenshell.api.context.add_context( + ifc, + context_type="Model", + context_identifier="Body", + target_view="MODEL_VIEW", + parent=parent, + ) + + def _create_wall_with_body_rep( + self, + ifc: ifcopenshell.file, + body_subcontext: ifcopenshell.entity_instance, + name: str = "Wall", + ) -> ifcopenshell.entity_instance: + wall = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall", name=name) + rep = ifc.createIfcShapeRepresentation( + ContextOfItems=body_subcontext, + RepresentationIdentifier="Body", + RepresentationType="SweptSolid", + Items=[ifc.createIfcExtrudedAreaSolid()], + ) + ifcopenshell.api.geometry.assign_representation(ifc, product=wall, representation=rep) + return wall + + def test_returns_zero_when_element_has_no_body_representation(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + wall = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall") + assert subject.get_sibling_occurrence_count(wall) == 0 + + def test_returns_zero_when_element_has_unique_body_representation(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + body = self._make_body_subcontext(ifc) + wall = self._create_wall_with_body_rep(ifc, body) + assert subject.get_sibling_occurrence_count(wall) == 0 + + def test_returns_sibling_count_excluding_self_and_type(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + body = self._make_body_subcontext(ifc) + wall_type = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWallType", name="WAL01") + type_rep = ifc.createIfcShapeRepresentation( + ContextOfItems=body, + RepresentationIdentifier="Body", + RepresentationType="SweptSolid", + Items=[ifc.createIfcExtrudedAreaSolid()], + ) + ifcopenshell.api.geometry.assign_representation(ifc, product=wall_type, representation=type_rep) + + occurrences = [ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall", name=f"Wall{i}") for i in range(3)] + ifcopenshell.api.type.assign_type(ifc, related_objects=occurrences, relating_type=wall_type) + + assert subject.get_sibling_occurrence_count(occurrences[0]) == 2 + assert subject.get_sibling_occurrence_count(occurrences[1]) == 2 + assert subject.get_sibling_occurrence_count(occurrences[2]) == 2 + + def test_type_with_occurrences_reports_its_occurrence_count(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + body = self._make_body_subcontext(ifc) + wall_type = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWallType", name="WAL01") + type_rep = ifc.createIfcShapeRepresentation( + ContextOfItems=body, + RepresentationIdentifier="Body", + RepresentationType="SweptSolid", + Items=[ifc.createIfcExtrudedAreaSolid()], + ) + ifcopenshell.api.geometry.assign_representation(ifc, product=wall_type, representation=type_rep) + occurrences = [ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall", name=f"Wall{i}") for i in range(2)] + ifcopenshell.api.type.assign_type(ifc, related_objects=occurrences, relating_type=wall_type) + + assert subject.get_sibling_occurrence_count(wall_type) == 2 diff --git a/src/bonsai/test/tool/test_spatial.py b/src/bonsai/test/tool/test_spatial.py index 005f370b01..ee2991e1c3 100644 --- a/src/bonsai/test/tool/test_spatial.py +++ b/src/bonsai/test/tool/test_spatial.py @@ -19,6 +19,9 @@ import bpy import ifcopenshell import ifcopenshell.api +import ifcopenshell.api.aggregate +import ifcopenshell.api.feature +import ifcopenshell.api.nest import ifcopenshell.api.root import ifcopenshell.api.spatial import numpy as np @@ -148,6 +151,40 @@ class TestGetContainer(NewFile): assert subject.get_container(wall) == site +class TestGetRootElement(NewFile): + def test_a_door_filling_a_wall_is_its_own_root_element(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + wall = ifc.createIfcWall() + opening = ifc.createIfcOpeningElement() + door = ifc.createIfcDoor() + ifcopenshell.api.feature.add_feature(ifc, feature=opening, element=wall) + ifcopenshell.api.feature.add_filling(ifc, opening=opening, element=door) + assert subject.get_root_element(door) == door + + def test_an_aggregated_element_walks_to_its_aggregate_root(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + assembly = ifc.createIfcElementAssembly() + beam = ifc.createIfcBeam() + ifcopenshell.api.aggregate.assign_object(ifc, products=[beam], relating_object=assembly) + assert subject.get_root_element(beam) == assembly + + def test_a_nested_element_walks_to_its_nest_root(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + parent_task = ifc.createIfcTask() + child_task = ifc.createIfcTask() + ifcopenshell.api.nest.assign_object(ifc, related_objects=[child_task], relating_object=parent_task) + assert subject.get_root_element(child_task) == parent_task + + def test_a_loose_element_is_its_own_root(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + wall = ifc.createIfcWall() + assert subject.get_root_element(wall) == wall + + class TestGetDecomposedElements(NewFile): def test_run(self): ifc = ifcopenshell.file() diff --git a/src/common.mk b/src/common.mk index 765655339b..cbc251fbe2 100644 --- a/src/common.mk +++ b/src/common.mk @@ -1,7 +1,7 @@ SHELL := sh IS_STABLE:=FALSE -PYTHON:=python3.11 -PIP:=pip3.11 +PYTHON:=python3 +PIP:=pip3 VERSION:=$(shell cat ../../VERSION) VERSION_DATE:=$(shell date '+%y%m%d') SED:=sed -i diff --git a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json index 596f39a2b5..02f6717028 100644 --- a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json +++ b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json @@ -237,7 +237,7 @@ "Area": "get_net_side_area", "Height": "get_height", "Perimeter": "get_rectangular_perimeter", - "Width": "get_length" + "Width": "get_x" } }, "IfcDuctFitting + IfcDuctFittingType": { diff --git a/src/ifc5d/ifc5d/typst_template_ifc_cost_schedule.typ b/src/ifc5d/ifc5d/typst_template_ifc_cost_schedule.typ index ffd87a3981..363b2e914b 100644 --- a/src/ifc5d/ifc5d/typst_template_ifc_cost_schedule.typ +++ b/src/ifc5d/ifc5d/typst_template_ifc_cost_schedule.typ @@ -26,7 +26,7 @@ -#let bill_of_quantities_table = table( +#let bill_of_quantities_table(currency: "") = table( columns: (18mm,54mm, 12mm,12mm,12mm,12mm, 20mm, 20mm, 25mm), rows: (6mm, 248mm), align: (center, left, center, center, center, center, center, center, center), @@ -36,12 +36,12 @@ top: 1pt, bottom: 1pt ), - [Hierarchy], [Description], [n°],[l],[w],[h/w], [Quantity], [Rate], [Total] + [Hierarchy], [Description], [n°],[l],[w],[h/w], [Quantity], [Rate (#currency)], [Total (#currency)] ) -#let schedule_of_rates_table = table( +#let schedule_of_rates_table(currency: "") = table( columns: (30mm,130mm, 25mm), rows: (6mm, 248mm), align: (center, left, center), @@ -51,12 +51,12 @@ top: 1pt, bottom: 1pt ), - [Identification], [Description], [Rate] + [Identification], [Description], [Rate (#currency)] ) -#let summary_table = table( +#let summary_table(currency: "") = table( columns: (18mm,107mm, 30mm, 30mm), rows: (6mm, 248mm), align: (center, left, center, center, center, center, center, center, center), @@ -67,9 +67,9 @@ bottom: 1pt ), text(size: 8pt)[Hierarchy], - text(size: 8pt)[Description], - text(size: 8pt)[Sub Total], - text(size: 8pt)[Total] + text(size: 8pt)[Description], + text(size: 8pt)[Sub Total (#currency)], + text(size: 8pt)[Total (#currency)] ) @@ -127,7 +127,6 @@ #let arrange_summary_row(row, options) = { let name = strong(upper(row.at("Name"))) let description = [#par(justify: true, text(8pt, row.at("Description", default: "")))] - let total = if row.at("RateSubtotal") == "" {0.0} else {float(row.at("RateSubtotal"))} if row.at("ItemIsASum") == "True" { if row.at("Index") == "1" { // ROOT COST @@ -216,7 +215,8 @@ format-decimal(float(row.at("Quantity")))} let rate = if row.at("RateSubtotal") == "" {0.0} else { format-decimal(float(row.at("RateSubtotal")))} - let total = if row.at("Quantity") == "" {0.0} else { + let total = if row.at("Quantity") == "" or row.at("RateSubtotal") == "" { + format-decimal(0.0, places: 2)} else { format-decimal(float(row.at("Quantity")) * float(row.at("RateSubtotal")), places: 2)} ( @@ -281,18 +281,18 @@ #let arrange_schedule_of_rates_row(row, options) = { let name = strong(upper(row.at("Name"))) let description = [#par(justify: true, text(8pt, row.at("Description", default: "")))] - let unit = table.cell(align: right)[#unit_map.at(row.at("Unit"), default: "")] + let unit = table.cell(align: right + bottom)[#unit_map.at(row.at("Unit"), default: "")] let rate = if row.at("RateSubtotal") == "" {0.0} else { format-decimal(float(row.at("RateSubtotal")))} if row.at("ItemIsASum") == "True" {return ()} //skip sections in schedule of rates ( row.at("Identification"), - if row.at("Identification") == "" {name + linebreak() + description} else {name + linebreak() + description}, + name + linebreak() + description, [] ) ( [], - table.cell(align: right+bottom)[#unit], + unit, table.cell(align: right+bottom)[#rate], ) ( @@ -342,8 +342,12 @@ ) = { let data = csv(path, delimiter: delimiter, row-type: dictionary) let new_rows = data.map(item => arrange_summary_row(item, options)) - let general_total = data.filter(row => row.at("ItemIsASum") == "False") - .map(row => float(row.at("RateSubtotal", default: 0.0))*float(row.at("Quantity", default: 0.0))) + let general_total = data.filter(row => row.at("ItemIsASum") == "False") + .map(row => { + let qty = if row.at("Quantity", default: "") == "" { 0.0 } else { float(row.at("Quantity")) } + let rate = if row.at("RateSubtotal", default: "") == "" { 0.0 } else { float(row.at("RateSubtotal")) } + qty * rate + }) .sum(default: 0.00) set text(size: 10pt) @@ -477,9 +481,9 @@ [#counter(page).display("1/1", both: true)] ) ], - background: + background: place( top + left, dx: 15mm, dy: 25mm, - format_table.at(schedule_type, default: bill_of_quantities_table) + (format_table.at(schedule_type, default: bill_of_quantities_table))(currency: project_currency) ) ) @@ -522,9 +526,9 @@ set page( background: place( top + left, dx: 15mm, dy: 25mm, - format_table.at("SUMMARY") + (format_table.at("SUMMARY"))(currency: project_currency) ) ) create-summary(schedule_path, options) } -} \ No newline at end of file +} diff --git a/src/ifcchat/ifc_worker.js b/src/ifcchat/ifc_worker.js index 517bc4b24c..06d6ecdf70 100644 --- a/src/ifcchat/ifc_worker.js +++ b/src/ifcchat/ifc_worker.js @@ -29,16 +29,7 @@ async function ensurePyodide() { const micropip = pyodide.pyimport("micropip"); micropip.install("python-dateutil") - // Detect python minor version (3.12 vs 3.13) and pick a matching wheel. - const pyVer = pyodide.runPython(` -import sys -f"{sys.version_info.major}.{sys.version_info.minor}" - `); - - const wheelUrl = - pyVer === "3.13" - ? "https://ifcopenshell.github.io/wasm-wheels/ifcopenshell-0.8.3+34a1bc6-cp313-cp313-emscripten_4_0_9_wasm32.whl" - : "https://ifcopenshell.github.io/wasm-wheels/ifcopenshell-0.8.2+d50e806-cp312-cp312-emscripten_3_1_58_wasm32.whl"; + const wheelUrl = "https://ifcopenshell.github.io/wasm-wheels/ifcopenshell-0.8.5-cp313-cp313-pyodide_2025_0_wasm32.whl"; await micropip.install(wheelUrl); diff --git a/src/ifcedit/README.md b/src/ifcedit/README.md index 19b1ec8e7a..1b8858fe5a 100644 --- a/src/ifcedit/README.md +++ b/src/ifcedit/README.md @@ -189,7 +189,7 @@ each JSON object. The model is opened once and saved once regardless of how many elements are processed. ```bash -ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id} +ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product '{id}' ``` ```json @@ -201,7 +201,7 @@ Placeholder tokens match the fields emitted by `ifcquery` — typically `{id}`, ```bash ifcquery model.ifc select 'IfcDoor' | ifcedit foreach model.ifc attribute.edit_attributes \ - --product {id} --attributes '{"Name": "Door"}' + --product '{id}' --attributes '{"Name": "Door"}' ``` **Options:** @@ -297,7 +297,7 @@ ifcedit run model.ifc spatial.unassign_container \ --products "$(ifcquery model.ifc --format ids select 'IfcWall')" # Fan-out — one operation per element, model opened and saved once -ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id} +ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product '{id}' ``` ## License diff --git a/src/ifcgeom/AbstractKernel.h b/src/ifcgeom/AbstractKernel.h index eeb8614351..84fa069c7e 100644 --- a/src/ifcgeom/AbstractKernel.h +++ b/src/ifcgeom/AbstractKernel.h @@ -157,10 +157,14 @@ namespace { template <> struct dispatch_conversion { - static bool dispatch(ifcopenshell::geometry::kernels::AbstractKernel* k, ifcopenshell::geometry::taxonomy::kinds, const ifcopenshell::geometry::taxonomy::ptr& item, IfcGeom::ConversionResults&) { - if (k->partial_success_is_success) { - logger::error("No conversion for " + std::to_string(item->kind())); - } + static bool dispatch(ifcopenshell::geometry::kernels::AbstractKernel* kernel, ifcopenshell::geometry::taxonomy::kinds, const ifcopenshell::geometry::taxonomy::ptr& item, IfcGeom::ConversionResults&) { + if (kernel->partial_success_is_success) { + std::string created_from; + if (item->instance) { + created_from = " (created from " + item->instance.declaration().name() + ")"; + } + logger::error("No support for " + ifcopenshell::geometry::taxonomy::kind_to_string(item->kind()) + created_from + " in kernel " + kernel->geometry_library()); + } return false; } }; @@ -179,10 +183,14 @@ namespace { template <> struct dispatch_with_upgrade { - static bool dispatch(ifcopenshell::geometry::kernels::AbstractKernel* k, const ifcopenshell::geometry::taxonomy::ptr& item, IfcGeom::ConversionResults&) { - if (k->partial_success_is_success) { - logger::error("No conversion with upgrade for " + std::to_string(item->kind())); - } + static bool dispatch(ifcopenshell::geometry::kernels::AbstractKernel* kernel, const ifcopenshell::geometry::taxonomy::ptr& item, IfcGeom::ConversionResults&) { + if (kernel->partial_success_is_success) { + std::string created_from; + if (item->instance) { + created_from = " (created from " + item->instance.declaration().name() + ")"; + } + logger::error("No support (after considering item upgrade) for " + ifcopenshell::geometry::taxonomy::kind_to_string(item->kind()) + created_from + " in kernel " + kernel->geometry_library()); + } return false; } }; diff --git a/src/ifcgeom/IfcGeomElement.h b/src/ifcgeom/IfcGeomElement.h index d1c7b3132b..8a27980ec7 100644 --- a/src/ifcgeom/IfcGeomElement.h +++ b/src/ifcgeom/IfcGeomElement.h @@ -36,13 +36,25 @@ namespace IfcGeom { class Transformation { private: ifcopenshell::geometry::Settings settings_; - ifcopenshell::geometry::taxonomy::matrix4::ptr matrix_; + ifcopenshell::geometry::taxonomy::matrix4::ptr matrix_, matrix_orig_units_; public: - Transformation(const ifcopenshell::geometry::Settings& settings, const ifcopenshell::geometry::taxonomy::matrix4::ptr& matrix) - : settings_(settings) - , matrix_(matrix) - {} + Transformation(const ifcopenshell::geometry::Settings& settings, const ifcopenshell::geometry::taxonomy::matrix4::ptr& matrix) + : settings_(settings), matrix_(matrix) + { + const bool convert = settings.get().get(); + auto unit_magnitude = settings.get().get(); + if (matrix_ && convert && unit_magnitude != 1.0) { + matrix_orig_units_ = ifcopenshell::geometry::taxonomy::make(*matrix); + // only multiple the translation components of the matrix with the unit magnitude, not the rotation/scaling components + matrix_orig_units_->components().col(3).head<3>() /= unit_magnitude; + } else { + matrix_orig_units_ = nullptr; + } + } const ifcopenshell::geometry::taxonomy::matrix4::ptr& data() const { + if (matrix_orig_units_) { + return matrix_orig_units_; + } if (matrix_) { return matrix_; } diff --git a/src/ifcgeom/function_item_evaluator.cpp b/src/ifcgeom/function_item_evaluator.cpp index 919ad1d0b1..9ef3b7a8e9 100644 --- a/src/ifcgeom/function_item_evaluator.cpp +++ b/src/ifcgeom/function_item_evaluator.cpp @@ -104,14 +104,6 @@ struct gradient_fn_evaluator : public fn_evaluator { auto xy = horizontal_evaluator_.evaluate(u); auto uz = vertical_evaluator_.evaluate(u); - // curvature is stored in row 3 - capture it and remove it from the xy and uz matrices - // so the matrix operations (ie multiplication) works correct.y - auto horizontal_curvature = xy.row(3); - xy.row(3) = Eigen::Vector4d(0, 0, 0, 1); - - auto vertical_curvature = uz.row(3); - uz.row(3) = Eigen::Vector4d(0, 0, 0, 1); - uz(0, 3) = 0.0; // x is distance along. zero it out so it doesn't add to the x from horizontal uz.col(1).swap(uz.col(2)); // uz is 2D in distance along - y plane, swap y and z so elevations become z uz.row(1).swap(uz.row(2)); @@ -119,12 +111,6 @@ struct gradient_fn_evaluator : public fn_evaluator { Eigen::Matrix4d m; m = xy * uz; // combine horizontal and vertical - // Put curvature back into the solution matrix - // curvature for vertical is in column 0, need it to be in column 1 - // so it doesn't add to curvature for horizontal - std::swap(vertical_curvature(0), vertical_curvature(1)); - m.row(3) = horizontal_curvature + vertical_curvature; - return m; } diff --git a/src/ifcgeom/kernel_registry.cpp b/src/ifcgeom/kernel_registry.cpp index 962710da43..79f8e9acef 100644 --- a/src/ifcgeom/kernel_registry.cpp +++ b/src/ifcgeom/kernel_registry.cpp @@ -81,7 +81,11 @@ namespace { try { module = manager.load(path); } catch (const std::exception& e) { +#ifdef IFOPSH_PLUGIN_DEBUG std::cerr << "[ifcopenshell.plugin] skip kernel plugin " << path << ": " << e.what() << std::endl; +#else + static_cast(e); +#endif continue; } if (module.meta().kind_ != ifcopenshell::plugin::kind::kernel) { diff --git a/src/ifcgeom/kernels/cgal/nef_to_halfspace_tree.h b/src/ifcgeom/kernels/cgal/nef_to_halfspace_tree.h index 0644ab75f4..8eea739c5f 100644 --- a/src/ifcgeom/kernels/cgal/nef_to_halfspace_tree.h +++ b/src/ifcgeom/kernels/cgal/nef_to_halfspace_tree.h @@ -116,6 +116,16 @@ template using plane_map = std::map>; // using plane_map = std::unordered_map>; +// Lexicographic comparator for CGAL Point_d (operator< is deleted in CGAL 6.x) +struct Point_d_4d_Less { + using Point_d = CGAL::Epick_d>::Point_d; + bool operator()(const Point_d& a, const Point_d& b) const { + return std::lexicographical_compare( + a.cartesian_begin(), a.cartesian_end(), + b.cartesian_begin(), b.cartesian_end()); + } +}; + // Snap halfspace planes // search_radius: max cartesian distance in plane equation parameters as 4d points in space template @@ -131,8 +141,8 @@ plane_map snap_halfspaces(const std::list>& planes plane_map result; - std::map> neighbours; - std::map>> originals; + std::map, Point_d_4d_Less> neighbours; + std::map>, Point_d_4d_Less> originals; std::vector planes_as_point; for (auto& p : planes) { @@ -205,7 +215,7 @@ plane_map snap_halfspaces_2(const std::list>& plan plane_map result; std::vector planes_as_point; - std::map> normalized_to_original; + std::map, Point_d_4d_Less> normalized_to_original; for (auto& p : planes_fixed) { // @todo can we skip normalization (simply divide by largest component perhaps) diff --git a/src/ifcgeom/kernels/opencascade/IfcGeomTree.h b/src/ifcgeom/kernels/opencascade/IfcGeomTree.h index 0bdb99151a..565bd4d535 100644 --- a/src/ifcgeom/kernels/opencascade/IfcGeomTree.h +++ b/src/ifcgeom/kernels/opencascade/IfcGeomTree.h @@ -49,6 +49,7 @@ #include #include #include +#include #include #include #include diff --git a/src/ifcgeom/kernels/opencascade/OpenCascadeKernel.cpp b/src/ifcgeom/kernels/opencascade/OpenCascadeKernel.cpp index 2c040fa9a6..93bf37f38e 100644 --- a/src/ifcgeom/kernels/opencascade/OpenCascadeKernel.cpp +++ b/src/ifcgeom/kernels/opencascade/OpenCascadeKernel.cpp @@ -31,6 +31,8 @@ #include #include +#include + namespace { struct opening_sorter { bool operator()(const std::pair& a, const std::pair& b) const { @@ -136,20 +138,44 @@ bool IfcGeom::OpenCascadeKernel::convert_openings(const express::Base& entity, c for (auto& entity_part : parts) { bool is_manifold = util::is_manifold(entity_part); + if (!is_manifold) { + // force sewing, edge identity might have been mudied by FixAdvFace.FixOrientation.MSG5 to fix interior loop winding order + TopTools_ListOfShape list; + IfcGeom::util::shape_to_face_list(entity_part, list); + IfcGeom::util::create_solid_from_faces(list, entity_part, settings_.get().get(), true); + is_manifold = util::is_manifold(entity_part); + if (is_manifold) { + logger::warning("Successfully sewed non-manifold first operand", entity); + } + } + if (!is_manifold) { if (settings_.get().get()) { BOPAlgo_MakerVolume mv; mv.AddArgument(entity_part); - mv.Perform(); - if (mv.HasErrors()) { - logger::warning("Non-manifold first operand, --make-volume failed"); - } else { - entity_part = mv.Shape(); - is_manifold = util::is_manifold(entity_part); + mv.SetAvoidInternalShapes(true); + // mv.SetFuzzyValue(settings_.get().get()); + std::optional failure; + try { + mv.Perform(); + auto entity_part_2 = mv.Shape(); + if (mv.HasErrors()) { + failure = "BOPAlgo_MakerVolume reported errors"; + } else if (IfcGeom::util::count(entity_part_2, TopAbs_FACE) == 0) { + failure = "Empty result (no faces) for BOPAlgo_MakerVolume; original was " + std::to_string(IfcGeom::util::count(entity_part, TopAbs_FACE)); + } else { + is_manifold = util::is_manifold(entity_part_2); + logger::warning(std::string("Successfully detected exterior volume to non-manifold first operand; shape is now ") + (is_manifold ? std::string("manifold") : std::string("non-manifold")), entity); + entity_part = entity_part_2; + } + } catch (const Standard_Failure& e) { + failure.emplace(e.GetMessageString()); } - } - if (!is_manifold) { - logger::warning("Non-manifold first operand, use --make-volume to try and make manifold"); + if (failure) { + logger::warning("MakeVolume failed: " + *failure, entity); + } + } else { + logger::warning("Non-manifold first operand, use --make-volume to try and make manifold", entity); } } diff --git a/src/ifcgeom/kernels/opencascade/clash_utils.cpp b/src/ifcgeom/kernels/opencascade/clash_utils.cpp index d6050bf87c..cfc7687b73 100644 --- a/src/ifcgeom/kernels/opencascade/clash_utils.cpp +++ b/src/ifcgeom/kernels/opencascade/clash_utils.cpp @@ -1,5 +1,7 @@ #include "clash_utils.h" #include +#include +#include #define GU_CULLING_EPSILON_RAY_TRIANGLE FLT_EPSILON*FLT_EPSILON #define PX_MAX_F32 3.4028234663852885981170418348452e+38F diff --git a/src/ifcgeom/kernels/opencascade/faceset_helper.cpp b/src/ifcgeom/kernels/opencascade/faceset_helper.cpp index dcb470d955..8963d8231d 100644 --- a/src/ifcgeom/kernels/opencascade/faceset_helper.cpp +++ b/src/ifcgeom/kernels/opencascade/faceset_helper.cpp @@ -154,7 +154,13 @@ IfcGeom::OpenCascadeKernel::faceset_helper::faceset_helper( typedef std::array edge_t; typedef std::set edge_set_t; - std::set edge_sets; + // When a single face fills an interior loop, their edge_sets (canonicalized edges) will be identical. + // We can differentiate in this scenario in two ways: + // - std::map retain the edge order from the bool passed to the loop_() lambda + // - std::pair with pair::first populated from external (FaceBound / OuterBound) + // The second has been found more reliable for typical models, because inner bound winding can be wrong. + // The can be made more resilient by first checking correct population of external and falling back to approach 1. + std::set> edge_sets; for (auto& loop : loops) { std::vector > segments; @@ -165,12 +171,12 @@ IfcGeom::OpenCascadeKernel::faceset_helper::faceset_helper( segments.push_back(std::make_pair(C, D)); }); - if (edge_sets.find(segment_set) != edge_sets.end()) { + if (edge_sets.find({loop->external.value_or(false), segment_set}) != edge_sets.end()) { duplicate_faces++; duplicates_.insert(loop->identity()); continue; } - edge_sets.insert(segment_set); + edge_sets.insert({loop->external.value_or(false), segment_set}); if (segments.size() >= 3) { for (auto& p : segments) { diff --git a/src/ifcgeom/kernels/opencascade/sweep_along_curve.cpp b/src/ifcgeom/kernels/opencascade/sweep_along_curve.cpp index 5af8dd72d0..b9f9b19440 100644 --- a/src/ifcgeom/kernels/opencascade/sweep_along_curve.cpp +++ b/src/ifcgeom/kernels/opencascade/sweep_along_curve.cpp @@ -300,7 +300,11 @@ bool OpenCascadeKernel::convert(const taxonomy::sweep_along_curve::ptr scs, Topo if (applied_temporary_offset) { gp_Trsf trsf; - trsf.SetTranslation(gp_Vec(-mean.x(), -mean.y(), -mean.z())); + // Restore original position: add back the mean subtracted from the + // directrix points above. Previously negated, which placed the swept + // solid at -mean instead of its original location for geometry far + // from the origin. + trsf.SetTranslation(gp_Vec(mean.x(), mean.y(), mean.z())); result.Move(trsf); } diff --git a/src/ifcgeom/mapping/IfcCurveSegment.cpp b/src/ifcgeom/mapping/IfcCurveSegment.cpp index 1efbed2fe1..8e6fac1259 100644 --- a/src/ifcgeom/mapping/IfcCurveSegment.cpp +++ b/src/ifcgeom/mapping/IfcCurveSegment.cpp @@ -133,23 +133,21 @@ struct spiral_parent_curve : public parent_curve_function { // this is the piecewise curve segment function for horizontal and vertical struct curve_segment_function { - curve_segment_function(const Eigen::Matrix4d& curve_segment_placement, const Eigen::Matrix4d& remove_parent_curve_rotation, const Eigen::Matrix4d& remove_parent_curve_translation, std::shared_ptr parent_curve_fn) : + curve_segment_function(const Eigen::Matrix4d& curve_segment_placement, const Eigen::Matrix4d& parent_curve_normalization, std::shared_ptr parent_curve_fn) : curve_segment_placement_(curve_segment_placement), - remove_parent_curve_rotation_(remove_parent_curve_rotation), - remove_parent_curve_translation_(remove_parent_curve_translation), + parent_curve_normalization_(parent_curve_normalization), parent_curve_fn_(parent_curve_fn) { } Eigen::Matrix4d operator()(double u) const { Eigen::Matrix4d parent_curve_point = (*parent_curve_fn_)(u); - Eigen::Matrix4d curve_segment_point = curve_segment_placement_ * remove_parent_curve_rotation_ * remove_parent_curve_translation_ * parent_curve_point; + Eigen::Matrix4d curve_segment_point = curve_segment_placement_ * parent_curve_normalization_ * parent_curve_point; return curve_segment_point + parent_curve_fn_->curvature(u); } private: Eigen::Matrix4d curve_segment_placement_; - Eigen::Matrix4d remove_parent_curve_rotation_; - Eigen::Matrix4d remove_parent_curve_translation_; + Eigen::Matrix4d parent_curve_normalization_; std::shared_ptr parent_curve_fn_; }; @@ -166,9 +164,46 @@ struct cant_curve_segment_function { // Subtract the parent_curve_start_point to get the incremental cant rotation and superelevation // Add the incremental cant rotation and superelevation to curve_segment_placement to get the curve_segment_point Eigen::Matrix4d parent_curve_point = (*parent_curve_fn_)(u); - Eigen::Matrix4d cant_increment = parent_curve_point - parent_curve_start_point_; - Eigen::Matrix4d curve_segment_point = curve_segment_placement_ + cant_increment; + + Eigen::Matrix3d parent_curve_start_rotation_ = parent_curve_start_point_.block<3, 3>(0, 0); + Eigen::Matrix3d parent_curve_point_rotation = parent_curve_point.block<3, 3>(0, 0); + Eigen::Matrix3d incremental_rotation = parent_curve_point_rotation * parent_curve_start_rotation_.transpose(); + Eigen::Matrix3d placement_rotation_ = curve_segment_placement_.block<3, 3>(0, 0); + Eigen::Matrix3d curve_segment_rotation = incremental_rotation * placement_rotation_; + + Eigen::Vector3d parent_curve_start_translation_ = parent_curve_start_point_.block<3, 1>(0, 3); + Eigen::Vector3d parent_curve_point_translation = parent_curve_point.block<3, 1>(0, 3); + Eigen::Vector3d incremental_translation = parent_curve_point_translation - parent_curve_start_translation_; + Eigen::Vector3d placement_translation_ = curve_segment_placement_.block<3, 1>(0, 3); + Eigen::Vector3d curve_segment_translation = incremental_translation + placement_translation_; + + Eigen::Matrix4d curve_segment_point = Eigen::Matrix4d::Identity(); + curve_segment_point.block<3, 3>(0, 0) = curve_segment_rotation; + curve_segment_point.block<3, 1>(0, 3) = curve_segment_translation; + + //if (0.0 < u) { + // Eigen::IOFormat latexFormat( + // Eigen::FullPrecision, // full precision + // 0, // no alignment flags + // " & ", // coeff separator + // " \\\\ \n", // row separator + // "", // row prefix + // "", // row suffix + // "", // matrix prefix + // "" // matrix suffix + // ); + // std::cout << "Placement (M_CSP)" << std::endl; + // std::cout << curve_segment_placement_.format(latexFormat) << std::endl; + // std::cout << "Parent curve start point (M_PCS)" << std::endl; + // std::cout << parent_curve_start_point_.format(latexFormat) << std::endl; + // std::cout << "Parent curve point (M_PCl)" << std::endl; + // std::cout << parent_curve_point.format(latexFormat) << std::endl; + // std::cout << "Curve segment point (M_c)" << std::endl; + // std::cout << curve_segment_point.format(latexFormat) << std::endl; + //} + return curve_segment_point + parent_curve_fn_->curvature(u); + } private: @@ -314,33 +349,26 @@ class curve_segment_evaluator { return taxonomy::make(length, fn); } else { // The parent curve function returns the 4x4 matrix for the parent curve. - // Subtract the parent curve start point (remove the translation and rotation) - // to get the incremental translation and rotation. Apply the incremental + // Normalize the parent curve so that the trim start point and tangent direction at the start point + // are aligned with the origin. This is accomplished with a normalization matrix that subtracts the + // incremental parent curve start point and applies a rotation. Apply the incremental // translation and rotation to the curve_segment_placement to get the curve_segment_point - // Do a negative translation of the parent curve point relative to the start of the parent curve. - // This moves parent_curve_fn(u=0.0) to coordinate (0,0). - // This is done so the curve_segment_placement is applied relative to (0,0) - Eigen::Matrix4d remove_parent_curve_translation = Eigen::Matrix4d::Identity(); - remove_parent_curve_translation.col(3) = -1.0 * (*parent_curve_start_point_).col(3); - remove_parent_curve_translation(3, 3) = 1.0; + auto rotation = (*parent_curve_start_point_).block<3, 3>(0, 0); + auto dxo = rotation(0, 0); + auto dyo = rotation(1, 0); + rotation(0, 1) *= -1.0; + rotation(1, 0) *= -1.0; + auto xo = (*parent_curve_start_point_)(0, 3); + auto yo = (*parent_curve_start_point_)(1, 3); + auto xn = -xo*dxo - yo*dyo; + auto yn = xo*dyo - yo*dxo; + Eigen::Matrix4d parent_curve_normalization = Eigen::Matrix4d::Identity(); + parent_curve_normalization.block<3, 3>(0, 0) = rotation; + parent_curve_normalization(0, 3) = xn; + parent_curve_normalization(1, 3) = yn; - // Do a rotation so that the tangent of the parent curve is in the direction (1,0) - // Example: if the parent curve IfcLine is at a 30 degree clockwise angle, this does - // a 30 degree counter-clockwise rotation - // Clockwise rotation matrix = [cos(angle) -sin(angle)] - // [sin(angle) cos(angle)] - // - // Counter-clockwise rotation = [ cos(angle) sin(angle)] - // [-sin(angle) cos(angle)] - // - // That's just a sign flip in positions (0,1) and (1,0) - Eigen::Matrix4d remove_parent_curve_rotation = (*parent_curve_start_point_); - remove_parent_curve_rotation(0, 1) *= -1.0; - remove_parent_curve_rotation(1, 0) *= -1.0; - remove_parent_curve_rotation.col(3) = Eigen::Vector4d(0, 0, 0, 1); // remove the parent curve placement point - - auto fn = curve_segment_function(*curve_segment_placement_, remove_parent_curve_rotation, remove_parent_curve_translation, parent_curve_fn_); + auto fn = curve_segment_function(*curve_segment_placement_, parent_curve_normalization, parent_curve_fn_); return taxonomy::make(length, fn); } } @@ -491,11 +519,10 @@ class curve_segment_evaluator { // tilt angle in the plane of the cross section auto cant = Cant(u); auto tilt_angle = start_angle + delta_angle * (cant - start_cant) / delta_cant; - Eigen::Vector4d z(0.0, cos(tilt_angle), sin(tilt_angle), 0.0); + Eigen::Vector4d axis(0.0, cos(tilt_angle), sin(tilt_angle), 0.0); - // compute axis direction - Eigen::Vector4d y = z.cross3(ref_dir); - Eigen::Vector4d axis = ref_dir.cross3(y); + // compute cross slope direction + Eigen::Vector4d y = axis.cross3(ref_dir); Eigen::Matrix4d m = Eigen::Matrix4d::Identity(); m.col(0) = ref_dir; @@ -827,6 +854,8 @@ class curve_segment_evaluator { auto R = c.Radius() * length_unit_; auto parent_curve_position = taxonomy::cast(mapping_->map(c.Position()))->ccomponents(); + auto sign_l = sign(length_); + // center point of the parent curve auto pcCenterX = parent_curve_position(0, 3); auto pcCenterY = parent_curve_position(1, 3); @@ -840,7 +869,8 @@ class curve_segment_evaluator { // angle from X = 0 to the first point on the trimmed curve auto start_angle = pc_axis_angle + sweep_start_angle; - auto sign_l = sign(length_); + auto pcStartX = pcCenterX + R * cos(start_angle); + auto pcStartY = pcCenterY + R * sin(start_angle); projected_length_ = length_; @@ -852,34 +882,27 @@ class curve_segment_evaluator { #ifdef SCHEMA_IfcCurveSegment_HAS_Placement curve_segment_placement = taxonomy::cast(mapping_->map(inst_.Placement()))->ccomponents(); #endif - auto csStartX = curve_segment_placement(0, 3); - auto csStartY = curve_segment_placement(1, 3); - auto csStartDx = curve_segment_placement(0, 0); - auto csStartDy = curve_segment_placement(1, 0); - auto csCenterX = csStartX - sign_l * csStartDy * R; - auto csCenterY = csStartY + sign_l * csStartDx * R; - // determine projected length along the x-axis auto subtended_angle = R ? length_ / R : 0.0; auto end_angle = start_angle + subtended_angle; - auto csEndX = csCenterX + R * cos(end_angle); - projected_length_ = csEndX - csStartX; + auto pcEndX = pcCenterX + R * cos(end_angle); + projected_length_ = pcEndX - pcStartX; - convert_u = [csStartX, csStartY, csCenterX, csCenterY, R, sign_l](double u) { + convert_u = [pcStartX, pcStartY, pcCenterX, pcCenterY, R, sign_l](double u) { // for vertical, u is measured along the horizonal but we need it to be an arc length // x and y are coordinates on the curve segment for horizontal distance u from the start point // u is a horizontal distance so x = csStartX + u // Recognizing the triangle - // R^2 = (u + csStartX - csCenterX)^2 + (y - csCenterY)^2 + // R^2 = (u + pcStartX - pcCenterX)^2 + (y - pcCenterY)^2 // solve for y - // (y - csCenterY) = sqrt( R^2 - (u + csStartX - csCenterX)^2 ) - // y = csCenterY + sqrt( R^2 - (u + csStartX - csCenterX)^2 ) - auto x = csStartX + u; - auto y = csCenterY - sign_l * sqrt(pow(R, 2) - pow(u + csStartX - csCenterX, 2)); + // (y - pcCenterY) = sqrt( R^2 - (u + pcStartX - pcCenterX)^2 ) + // y = pcCenterY + sqrt( R^2 - (u + pcStartX - pcCenterX)^2 ) + auto x = pcStartX + u; + auto y = pcCenterY - sign_l * sqrt(pow(R, 2) - pow(u + pcStartX - pcCenterX, 2)); // compute the chord distance between the start point and (x,y) - auto c = sqrt(pow(x - csStartX, 2.0) + pow(y - csStartY, 2.0)); + auto c = sqrt(pow(x - pcStartX, 2.0) + pow(y - pcStartY, 2.0)); // compute the subtended angle // c = 2R*sin(delta/2) @@ -982,18 +1005,18 @@ class curve_segment_evaluator { double m_squared = std::inner_product(dr.begin(), dr.end(), dr.begin(), 0.0); double m = sqrt(m_squared); std::transform(dr.begin(), dr.end(), dr.begin(), [m](auto& d) { return d / m; }); - auto pcDx = dr[0]; - auto pcDy = dr[1]; + auto pcDXx = dr[0]; + auto pcDXy = dr[1]; if (segment_type_ == ST_VERTICAL && curve_segment_placement_) { // the general algorithm for mapping parent curve onto curve segment doesn't // exactly work for IfcLine. This is easily overcome by using the curve segment // placement for the IfcLine direction - pcDx = (*curve_segment_placement_)(0, 0); - pcDy = (*curve_segment_placement_)(1, 0); + pcDXx = (*curve_segment_placement_)(0, 0); + pcDXy = (*curve_segment_placement_)(1, 0); // projected length along the x-axis is the 'i' component of the total length - projected_length_ = length_ * pcDx; + projected_length_ = length_ * pcDXx; } if (segment_type_ == ST_HORIZONTAL || segment_type_ == ST_VERTICAL || segment_type_ == ST_CANT) { @@ -1002,19 +1025,32 @@ class curve_segment_evaluator { convert_u = [](double u) { return u; }; // u is along curve } else { // u is along horizontal, convert to along curve - convert_u = [pcDx](double u) { return u/pcDx; }; + convert_u = [pcDXx](double u) { return u/pcDXx; }; } + auto pcDZy = curve_segment_placement_ ? (*curve_segment_placement_)(1, 2) : 0.; + auto pcDZz = curve_segment_placement_ ? (*curve_segment_placement_)(2, 2) : 1.; + parent_curve_fn_ = std::make_shared( - [pcX, pcY, pcDx, pcDy, convert_u](double u)->Eigen::Matrix4d { + [segment_type = segment_type_,pcX, pcY, pcDXx, pcDXy, pcDZy, pcDZz, convert_u](double u)->Eigen::Matrix4d { u = convert_u(u); - auto x = pcX + pcDx * u; - auto y = pcY + pcDy * u; + auto x = pcX + pcDXx * u; + auto y = pcY + pcDXy * u; Eigen::Matrix4d m = Eigen::Matrix4d::Identity(); - m.col(0) = Eigen::Vector4d(pcDx, pcDy, 0, 0); - m.col(1) = Eigen::Vector4d(-pcDy, pcDx, 0, 0); + Eigen::Vector3d X(pcDXx, pcDXy, 0); + Eigen::Vector3d Z(0, 0, 1); + + if (segment_type == ST_CANT) { + Z = Eigen::Vector3d(0, pcDZy, pcDZz); + } + + Eigen::Vector3d Y = Z.cross(X).normalized(); + + m.col(0) = Eigen::Vector4d(X[0], X[1], X[2], 0); + m.col(1) = Eigen::Vector4d(Y[0], Y[1], Y[2], 0); + m.col(2) = Eigen::Vector4d(Z[0], Z[1], Z[2], 0); m.col(3) = Eigen::Vector4d(x, y, 0.0, 1.0); return m; }, diff --git a/src/ifcgeom/mapping/IfcPointByDistanceExpression.cpp b/src/ifcgeom/mapping/IfcPointByDistanceExpression.cpp index e9f5dc6f23..b028e72b71 100644 --- a/src/ifcgeom/mapping/IfcPointByDistanceExpression.cpp +++ b/src/ifcgeom/mapping/IfcPointByDistanceExpression.cpp @@ -52,6 +52,15 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcPointByDistanceExpression& i if (inst.OffsetVertical().has_value()) { auto offset_vertical = inst.OffsetVertical().value() * length_unit_; o += offset_vertical * z; + + auto tmp1 = (z * offset_vertical).eval(); + auto tmp2 = (Eigen::Vector3d(0, 0, 1) * offset_vertical).eval(); + auto tmp3 = (tmp1 - tmp2).eval(); + + std::ostringstream oss; + oss << "local z: " << z.x() << "," << z.y() << "," << z.z() << "; delta: " << tmp3.x() << "," << tmp3.y() << "," << tmp3.z(); + auto osss = oss.str(); + std::wcout << osss.c_str() << std::endl; } if (inst.OffsetLongitudinal().has_value()) { diff --git a/src/ifcgeom/mapping/mapping.cpp b/src/ifcgeom/mapping/mapping.cpp index 206d2e239c..9ac899e31a 100644 --- a/src/ifcgeom/mapping/mapping.cpp +++ b/src/ifcgeom/mapping/mapping.cpp @@ -197,8 +197,14 @@ std::vector mapping::find_openings(const express::Base& inst) { // Only aggregation, not nesting is considered. break; } - auto rel_obdef = decomposes.front().as().RelatingObject(); - if (rel_obdef.as() && !rel_obdef.as()) { + IfcSchema::IfcObjectDefinition rel_obdef; + try { + rel_obdef = decomposes.front().as().RelatingObject(); + } catch (const std::exception&) { + // exception already logged as part of handling of placement + break; + } + if (rel_obdef && rel_obdef.as() && !rel_obdef.as()) { auto element = rel_obdef.as(); auto rels = element.HasOpenings(); for (auto& rel : rels) { @@ -578,6 +584,7 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcMaterial& material) { } // Check if it's failed or just some unsupported case. if (failed_on_purpose_.find(styled_item) == failed_on_purpose_.end()) { + failed_on_purpose_.insert(material); return nullptr; } logger::warning("Skipping unsupported material style for material: ", material); @@ -585,6 +592,7 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcMaterial& material) { } // When material does not have a representation we don't create a style from it + failed_on_purpose_.insert(material); return nullptr; /* diff --git a/src/ifcgeom/mapping/mapping.h b/src/ifcgeom/mapping/mapping.h index 1055fe093b..3cda260568 100644 --- a/src/ifcgeom/mapping/mapping.h +++ b/src/ifcgeom/mapping/mapping.h @@ -8,6 +8,7 @@ #include #include +#include #define INCLUDE_SCHEMA(x) STRINGIFY(../../ifcparse/schemas/x.h) #include INCLUDE_SCHEMA(IfcSchema) diff --git a/src/ifcgeom/taxonomy.h b/src/ifcgeom/taxonomy.h index 81c48be3f9..a725ca2be9 100644 --- a/src/ifcgeom/taxonomy.h +++ b/src/ifcgeom/taxonomy.h @@ -17,6 +17,13 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include #ifndef TAXONOMY_USE_UNIQUE_PTR #ifndef TAXONOMY_USE_NAKED_PTR @@ -1623,25 +1630,25 @@ typedef item const* ptr; // @todo Sad... now that we have templated collection members, // we can't generally use collection_base anymore as a cast target. if (auto s = taxonomy::dcast(i)) { - visit(s, fn); + ifcopenshell::geometry::visit(s, fn); } else if (auto s = taxonomy::dcast(i)) { - visit(s, fn); + ifcopenshell::geometry::visit(s, fn); } else if (auto s = taxonomy::dcast(i)) { - visit(s, fn); + ifcopenshell::geometry::visit(s, fn); } else if (auto s = taxonomy::dcast(i)) { - visit(s, fn); + ifcopenshell::geometry::visit(s, fn); } else if (auto s = taxonomy::dcast(i)) { - visit(s, fn); + ifcopenshell::geometry::visit(s, fn); } else if (auto s = taxonomy::dcast(i)) { - visit(s, fn); + ifcopenshell::geometry::visit(s, fn); } else if (auto s = taxonomy::dcast(i)) { - visit(s, fn); + ifcopenshell::geometry::visit(s, fn); } else { fn(i); @@ -1756,4 +1763,4 @@ typedef item const* ptr; } -#endif \ No newline at end of file +#endif diff --git a/src/ifcopenshell-python/Makefile b/src/ifcopenshell-python/Makefile index 643735ba37..7d6592635d 100644 --- a/src/ifcopenshell-python/Makefile +++ b/src/ifcopenshell-python/Makefile @@ -5,8 +5,8 @@ VERSION_DATE:=$(shell date '+%y%m%d') PYVERSION:=py311 PLATFORM:=linux64 -PYTHON:=python3.11 -PIP:=pip3.11 +PYTHON:=python3 +PIP:=pip3 SED:=sed -i VENV_ACTIVATE:=bin/activate diff --git a/src/ifcopenshell-python/docs/ifcedit.rst b/src/ifcopenshell-python/docs/ifcedit.rst index d5db9b7c82..450d8e221a 100644 --- a/src/ifcopenshell-python/docs/ifcedit.rst +++ b/src/ifcopenshell-python/docs/ifcedit.rst @@ -57,13 +57,13 @@ Dry-run to validate without modifying the file:: Apply an API function to each element in a JSON array from stdin (``{field}`` placeholders are substituted from each item; model is opened and saved once):: - $ ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id} + $ ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product '{id}' $ ifcquery model.ifc select 'IfcDoor' | ifcedit foreach model.ifc attribute.edit_attributes \ - --product {id} --attributes '{"Name": "Door"}' + --product '{id}' --attributes '{"Name": "Door"}' Write to a separate output file instead of overwriting:: - $ ifcquery model.ifc select 'IfcWall' | ifcedit foreach model.ifc root.remove_product -o output.ifc --product {id} + $ ifcquery model.ifc select 'IfcWall' | ifcedit foreach model.ifc root.remove_product -o output.ifc --product '{id}' Quantity take-off (writes ``IfcElementQuantity`` psets back to the file; requires C++ geometry bindings):: diff --git a/src/ifcopenshell-python/docs/ifcquery.rst b/src/ifcopenshell-python/docs/ifcquery.rst index 8735b63da2..399f542b9c 100644 --- a/src/ifcopenshell-python/docs/ifcquery.rst +++ b/src/ifcopenshell-python/docs/ifcquery.rst @@ -86,7 +86,7 @@ pass query results directly into ``ifcedit run`` parameters, or pipe JSON into --products "$(ifcquery model.ifc --format ids select 'IfcWall')" # Fan-out — one operation per element, model opened and saved once - $ ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id} + $ ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product '{id}' # Render an element highlighted against everything related to it $ ifcquery model.ifc render -o relations.png \ diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py index 2662015313..13f4feefe5 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py @@ -70,8 +70,10 @@ from .get_basis_curve import get_basis_curve from .get_cant_layout import get_cant_layout from .get_child_alignments import get_child_alignments from .get_curve import get_curve +from .get_curve_segment import get_curve_segment from .get_curve_segment_transition_code import get_curve_segment_transition_code from .get_horizontal_layout import get_horizontal_layout +from .get_layout import get_layout from .get_layout_curve import get_layout_curve from .get_layout_segments import get_layout_segments from .get_mapped_segments import get_mapped_segments @@ -86,6 +88,7 @@ from .layout_vertical_alignment_by_pi_method import ( layout_vertical_alignment_by_pi_method, ) from .name_segments import name_segments +from .update_end_point import update_end_point from .update_fallback_position import update_fallback_position from .util import * @@ -112,8 +115,10 @@ __all__ = [ "get_cant_layout", "get_child_alignments", "get_curve", + "get_curve_segment", "get_curve_segment_transition_code", "get_horizontal_layout", + "get_layout", "get_layout_curve", "get_layout_segments", "get_parent_alignment", @@ -124,6 +129,7 @@ __all__ = [ "layout_vertical_alignment_by_pi_method", "name_segments", "register_referent_name_callback", + "update_end_point", "update_fallback_position", "get_mapped_segments", ] diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/_add_segment_to_curve.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/_add_segment_to_curve.py index 128ed5223c..3f71ddbbdd 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/_add_segment_to_curve.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/_add_segment_to_curve.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . +from typing import Union import numpy as np import ifcopenshell @@ -24,6 +25,9 @@ import ifcopenshell.geom import ifcopenshell.ifcopenshell_wrapper as ifcopenshell_wrapper import ifcopenshell.util.unit from ifcopenshell import entity_instance +from ifcopenshell.api.alignment._get_segment_endpoint import _get_segment_endpoint +from ifcopenshell.api.alignment._update_zero_length_segment_placement import _update_zero_length_segment_placement + from ifcopenshell.api.alignment._map_alignment_cant_segment import ( _map_alignment_cant_segment, ) @@ -39,11 +43,26 @@ from ifcopenshell.api.alignment._update_curve_segment_transition_code import ( def _add_curve_segment_to_composite_curve( - file: ifcopenshell.file, curve_segment: entity_instance, composite_curve: entity_instance -): + file: ifcopenshell.file, + layout_segment: entity_instance, + curve_segment: entity_instance, + composite_curve: entity_instance, +) -> Union[np.array, None]: + """ + Adds a curve segment to a composite curve and returns the end point of the added segment. + + :param file: The IFC file + :param layout_segment: The layout segment + :param curve_segment: The curve segment to be added + :param composite_curve: The composite curve to which the segment will be added + :return: The end point of the added segment or None if an error occurs + """ if 0 < len(curve_segment.UsingCurves): raise TypeError("IfcCurveSegment cannot belong to other curves") + prev_segment = None + zero_length_segment = None + settings = ifcopenshell.geom.settings() if composite_curve.Segments == None or 0 == len(composite_curve.Segments): # this is the first segment so just add it @@ -56,22 +75,29 @@ def _add_curve_segment_to_composite_curve( composite_curve.Segments += (curve_segment,) assert len(curve_segment.UsingCurves) == 1 else: + # not the first segment, so get the zero_length segment (if it exists) zero_length_segment = ( composite_curve.Segments[-1] if ifcopenshell.api.alignment.has_zero_length_segment(composite_curve) else None ) - prev_segment = None + # get the previous segment, which is either the on preceeding the zero length segment (if it exists) or + # the last curve segment if there is no zero length segment. + # This segment's transition code will need to be updated to match the new curve segment. if zero_length_segment and 1 < len(composite_curve.Segments): prev_segment = composite_curve.Segments[-2] elif zero_length_segment == None: prev_segment = composite_curve.Segments[-1] - curve_segment.Transition = "CONTINUOUS" + # IfcCompositeCurve is supposed to be comprised of continuous segments + curve_segment.Transition = "DISCONTINUOUS" + # get a list of all but the last segment (skips the zero length segment, if it exists) segments = composite_curve.Segments[0:-1] if zero_length_segment: + # if there is a zero length segment, need to append new curve_segment and the zero length segment to the array + # them update the composite curve segments with the new array segments += ( curve_segment, zero_length_segment, @@ -79,31 +105,23 @@ def _add_curve_segment_to_composite_curve( composite_curve.Segments = [] composite_curve.Segments += segments else: + # if there is no zero length segment, we can just append the new curve segment to the existing array of segments composite_curve.Segments += (curve_segment,) - if prev_segment: - _update_curve_segment_transition_code(prev_segment, curve_segment) + if prev_segment: + _update_curve_segment_transition_code(prev_segment, curve_segment) - if zero_length_segment: - settings = ifcopenshell.geom.settings() - segment_fn = ifcopenshell_wrapper.map_shape(settings, curve_segment) - segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn) - e = segment_evaluator.evaluate(segment_fn.end()) - end = np.array(e) - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) - x = float(end[0, 3]) / unit_scale - y = float(end[1, 3]) / unit_scale - dx = float(end[0, 0]) - dy = float(end[1, 0]) + end_point = _get_segment_endpoint(file, layout_segment) + if zero_length_segment: + _update_zero_length_segment_placement(file, zero_length_segment, end_point) + _update_curve_segment_transition_code(curve_segment, zero_length_segment) - # assume IfcAxis2Placement2D - zero_length_segment.Placement.Location.Coordinates = (x, y) - zero_length_segment.Placement.RefDirection.DirectionRatios = (dx, dy) - - _update_curve_segment_transition_code(curve_segment, zero_length_segment) + return end_point -def _add_segment_to_curve(file: ifcopenshell.file, segment: entity_instance, curve: entity_instance) -> None: +def _add_segment_to_curve( + file: ifcopenshell.file, layout_segment: entity_instance, curve: entity_instance +) -> Union[np.array, None]: """ Creates an IfcCurveSegment from the IfcAlignmentSegment and adds it to the representation curve. The IfcCurveSegment is added at the end of the curve, but before the manditory zero length segment. The IfcCurveSegment.Transition for the segment @@ -114,16 +132,18 @@ def _add_segment_to_curve(file: ifcopenshell.file, segment: entity_instance, cur :return: None """ expected_types = ["IfcAlignmentSegment"] - if not segment.is_a() in expected_types: + if not layout_segment.is_a() in expected_types: raise TypeError( - f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received '{segment.is_a()}" + f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received '{layout_segment.is_a()}" ) - if segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment") and not curve.is_a("IfcCompositeCurve"): + if layout_segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment") and not curve.is_a("IfcCompositeCurve"): raise TypeError(f"Expected to see IfcCompositeCurve, instead received '{curve.is_a()}'.") - elif segment.DesignParameters.is_a("IfcAlignmentVerticalSegment") and not curve.is_a("IfcGradientCurve"): + elif layout_segment.DesignParameters.is_a("IfcAlignmentVerticalSegment") and not curve.is_a("IfcGradientCurve"): raise TypeError(f"Expected to see IfcGradientCurve, instead received '{curve.is_a()}'.") - elif segment.DesignParameters.is_a("IfcAlignmentCantSegment") and not curve.is_a("IfcSegmentedReferenceCurve"): + elif layout_segment.DesignParameters.is_a("IfcAlignmentCantSegment") and not curve.is_a( + "IfcSegmentedReferenceCurve" + ): raise TypeError(f"Expected to see IfcSegmentedReferenceCurve, instead received '{curve.is_a()}'.") expected_type = "IfcCompositeCurve" @@ -131,16 +151,18 @@ def _add_segment_to_curve(file: ifcopenshell.file, segment: entity_instance, cur raise TypeError(f"Expected to see {expected_type}, instead received {curve.is_a()}.") # map the IfcAlignmentSegment to an IfcCurveSegment (or two in the case of helmert curves) - if segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment"): - mapped_segments = _map_alignment_horizontal_segment(file, segment) - elif segment.DesignParameters.is_a("IfcAlignmentVerticalSegment"): - mapped_segments = _map_alignment_vertical_segment(file, segment) - elif segment.DesignParameters.is_a("IfcAlignmentCantSegment"): - cant_layout = segment.Nests[0].RelatingObject - mapped_segments = _map_alignment_cant_segment(file, segment, cant_layout.RailHeadDistance) + if layout_segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment"): + mapped_segments = _map_alignment_horizontal_segment(file, layout_segment) + elif layout_segment.DesignParameters.is_a("IfcAlignmentVerticalSegment"): + mapped_segments = _map_alignment_vertical_segment(file, layout_segment) + elif layout_segment.DesignParameters.is_a("IfcAlignmentCantSegment"): + cant_layout = layout_segment.Nests[0].RelatingObject + mapped_segments = _map_alignment_cant_segment(file, layout_segment, cant_layout.RailHeadDistance) else: assert False for mapped_segment in mapped_segments: if mapped_segment: - _add_curve_segment_to_composite_curve(file, mapped_segment, curve) + end_point = _add_curve_segment_to_composite_curve(file, layout_segment, mapped_segment, curve) + + return end_point diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/_add_segment_to_layout.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/_add_segment_to_layout.py index 72c0669279..2e643272d5 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/_add_segment_to_layout.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/_add_segment_to_layout.py @@ -16,12 +16,14 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . -import math +from typing import Union import numpy as np import ifcopenshell import ifcopenshell.api.alignment +from ifcopenshell.api.alignment import _map_alignment_cant_segment +from ifcopenshell.api.alignment._update_zero_length_segment_placement import _update_zero_length_segment_placement import ifcopenshell.api.nest import ifcopenshell.api.pset import ifcopenshell.geom @@ -29,15 +31,29 @@ import ifcopenshell.util.alignment import ifcopenshell.util.unit from ifcopenshell import entity_instance, ifcopenshell_wrapper from ifcopenshell.api.alignment._add_segment_to_curve import _add_segment_to_curve +from ifcopenshell.api.alignment._get_segment_endpoint import _get_segment_endpoint from ifcopenshell.api.alignment._get_segment_start_point_label import ( _get_segment_start_point_label, ) +from ifcopenshell.api.alignment._map_alignment_cant_segment import ( + _map_alignment_cant_segment, +) +from ifcopenshell.api.alignment._map_alignment_horizontal_segment import ( + _map_alignment_horizontal_segment, +) +from ifcopenshell.api.alignment._map_alignment_vertical_segment import ( + _map_alignment_vertical_segment, +) -def _add_segment_to_layout(file: ifcopenshell.file, layout: entity_instance, segment: entity_instance) -> None: +def _add_segment_to_layout( + file: ifcopenshell.file, layout: entity_instance, layout_segment: entity_instance +) -> Union[np.array, None]: """ Adds an IfcAlignmentSegment to a layout alignment (IfcAlignmentHorizontal/Vertical/Cant). This segment is added at the end - of the layout, before the manditory zero length segment. An IfcCurveSegment is created for the corresponding geometric representation. + of the layout, before the manditory zero length segment (if it exists). + If the layout has a corresponding geometric representation, an IfcCurveSegment is created for it and appended at the end + of the representation curve, before the zero length segment (if it exists). :param layout: The layout alignment :param segment: The segment to be appended @@ -50,160 +66,31 @@ def _add_segment_to_layout(file: ifcopenshell.file, layout: entity_instance, seg f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received {layout.is_a()}" ) - if not (segment.is_a("IfcAlignmentSegment")): - raise TypeError(f"Expected to see IfcAlignmentSegment, instead received {segment.is_a()}.") - - curve = ifcopenshell.api.alignment.get_layout_curve(layout) + if not (layout_segment.is_a("IfcAlignmentSegment")): + raise TypeError(f"Expected to see IfcAlignmentSegment, instead received {layout_segment.is_a()}.") # add the new segment to the layout - ifcopenshell.api.nest.assign_object(file, related_objects=[segment], relating_object=layout) + ifcopenshell.api.nest.assign_object(file, related_objects=[layout_segment], relating_object=layout) # segment is attached at the end, but this is after the zero length segment # swap the last two segments - ifcopenshell.api.nest.reorder_nesting(file, segment, -1, -1) + ifcopenshell.api.nest.reorder_nesting(file, layout_segment, -1, -1) + # For cant segments, the end point depends on the next segment. The next segment is the + # zero-length segment and it hasn't been updated to match the end point. + # For this reason, we can't compute the end point from the IfcCurveSegment, but instead we + # compute it from the layout segment design parameters. + end_point = _get_segment_endpoint(file, layout_segment) + + # update the position of the zero length layout segment to be at the end point of the newly added segment + segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(layout) + zero_length_layout_segment = segment_nest.RelatedObjects[-1] + _update_zero_length_segment_placement(file, zero_length_layout_segment, end_point) + + # if there is a curve defined, add a new IfcCurveSegment to it. + # _add_segment_to_curve maps the layout segment to the appropriate IfcCurveSegment type and adds it to the curve. + curve = ifcopenshell.api.alignment.get_layout_curve(layout) if curve: - # add the new segment to the geometric representation curve - _add_segment_to_curve(file, segment, curve) + _add_segment_to_curve(file, layout_segment, curve) - # gather information to: - # (1) add a referent at the start of this segment - # (2) update the name of the zero length segment's referent - - # get the distance along the alignment to the start of the new segment - dist_along = 0.0 - if layout.is_a("IfcAlignmentHorizontal"): - for nest in layout.IsNestedBy: - for seg in nest.RelatedObjects: - if seg.is_a("IfcAlignmentSegment"): - dist_along += seg.DesignParameters.SegmentLength - - # the length of the current segment is in dist_along, so subtract it out - dist_along -= segment.DesignParameters.SegmentLength - else: - dist_along = segment.DesignParameters.StartDistAlong - - # get the station of the start of the segment - alignment = ifcopenshell.api.alignment.get_alignment(layout) - start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment) - station = start_station + dist_along - - # update the zero length layout segment - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) - - segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(layout) - zero_length_segment = segment_nest.RelatedObjects[-1] - mapped_segments = ifcopenshell.api.alignment.get_mapped_segments(segment) - mapped_segment = mapped_segments[0] if mapped_segments[1] == None else mapped_segments[1] - - # compute the end point matrix - settings = ifcopenshell.geom.settings() - segment_fn = ifcopenshell_wrapper.map_shape(settings, mapped_segment) - segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn) - e = segment_evaluator.evaluate(segment_fn.end()) - end = np.array(e) - - # update the zero length segment semantic representation parameters - if zero_length_segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment"): - x = float(end[0, 3]) / unit_scale - y = float(end[1, 3]) / unit_scale - dx = float(end[0, 0]) - dy = float(end[1, 0]) - zero_length_segment.DesignParameters.StartPoint.Coordinates = (x, y) - zero_length_segment.DesignParameters.StartDirection = dy / dx - elif zero_length_segment.DesignParameters.is_a("IfcAlignmentVerticalSegment"): - y = float(end[1, 3]) / unit_scale - zero_length_segment.DesignParameters.StartHeight = y - dx = float(end[0, 0]) - dy = float(end[1, 0]) - zero_length_segment.DesignParameters.StartGradient = dy / dx - zero_length_segment.DesignParameters.EndGradient = zero_length_segment.DesignParameters.StartGradient - else: - z = float(end[2, 3]) / unit_scale - dx = float(end[0, 1]) - dy = float(end[1, 1]) - dz = float(end[2, 1]) - ds = math.sqrt(dx * dx + dy * dy) - slope = dz / ds - railhead = layout.RailHeadDistance - - zero_length_segment.DesignParameters.StartCantLeft = z + slope * railhead / 2.0 - zero_length_segment.DesignParameters.StartCantRight = z - slope * railhead / 2.0 - - # updated the referent's name because the referent is now at a new station - start_dist_along = 0.0 - if segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment"): - start_dist_along = dist_along + segment.DesignParameters.SegmentLength - else: - start_dist_along = segment.DesignParameters.StartDistAlong + segment.DesignParameters.HorizontalLength - zero_length_segment.DesignParameters.StartDistAlong = start_dist_along - - end_referent = zero_length_segment.PositionedRelativeTo[0].RelatingPositioningElement - end_referent.Name = f"{_get_segment_start_point_label(zero_length_segment,None)} ({ifcopenshell.util.alignment.station_as_string(file,start_station+start_dist_along)})" - - # update the referent's geometric representation's location - end_referent.ObjectPlacement.RelativePlacement.Location.DistanceAlong.wrappedValue = start_dist_along - settings = ifcopenshell.geom.settings() - basis_curve = ifcopenshell.api.alignment.get_basis_curve(alignment) - curve_fn = ifcopenshell_wrapper.map_shape(settings, basis_curve) - curve_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, curve_fn) - p = curve_evaluator.evaluate(start_dist_along * unit_scale) - p = np.array(p) - - x = float(p[0, 3]) / unit_scale - y = float(p[1, 3]) / unit_scale - z = float(p[2, 3]) / unit_scale - - rx = float(p[0, 0]) - ry = float(p[1, 0]) - rz = float(p[2, 0]) - - ax = float(p[0, 2]) - ay = float(p[1, 2]) - az = float(p[2, 2]) - - end_referent.ObjectPlacement.CartesianPosition.Location.Coordinates = (x, y, z) - end_referent.ObjectPlacement.CartesianPosition.Axis.DirectionRatios = (ax, ay, az) - end_referent.ObjectPlacement.CartesianPosition.RefDirection.DirectionRatios = (rx, ry, rz) - - start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment) - end_referent_station = start_station + start_dist_along - pset_stationing = ifcopenshell.api.pset.add_pset(file, product=end_referent, name="Pset_Stationing") - ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties={"Station": end_referent_station}) - - # create the start of segment referent - - # get the previous segment. Working from the end of the basis curve, -1 is zero length segment - # -2 is the newly added segment, so -3 is the segment occuring just before the newly added segment - prev_segment = segment_nest.RelatedObjects[-3] if 2 < len(segment_nest.RelatedObjects) else None - name = f"{_get_segment_start_point_label(prev_segment,segment)} ({ifcopenshell.util.alignment.station_as_string(file,station)})" - referent = ifcopenshell.api.alignment.add_stationing_referent( - file, alignment, distance_along=dist_along, station=station, name=name, positioned_product=segment - ) - - if len(curve.Segments) == 2 and layout.is_a("IfcAlignmentHorizontal"): - # this is the first real segment in the horizontal alignment - # update the location of the alignment's stationing referent - alignment = ifcopenshell.api.alignment.get_alignment(layout) - ref_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment) - stationing_referent = ref_nest.RelatedObjects[0] - p = curve_evaluator.evaluate( - stationing_referent.ObjectPlacement.RelativePlacement.Location.DistanceAlong.wrappedValue - ) - p = np.array(p) - - x = float(p[0, 3]) / unit_scale - y = float(p[1, 3]) / unit_scale - z = float(p[2, 3]) / unit_scale - - rx = float(p[0, 0]) - ry = float(p[1, 0]) - rz = float(p[2, 0]) - - ax = float(p[0, 2]) - ay = float(p[1, 2]) - az = float(p[2, 2]) - - stationing_referent.ObjectPlacement.CartesianPosition.Location.Coordinates = (x, y, z) - stationing_referent.ObjectPlacement.CartesianPosition.Axis.DirectionRatios = (ax, ay, az) - stationing_referent.ObjectPlacement.CartesianPosition.RefDirection.DirectionRatios = (rx, ry, rz) + return end_point diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/_add_zero_length_segment.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/_add_zero_length_segment.py index 71da0d3938..72302cc40e 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/_add_zero_length_segment.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/_add_zero_length_segment.py @@ -42,17 +42,8 @@ def _add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance) - f"Expected layout type to be one of {[_ for _ in expected_types]}, instead received {layout.is_a()}" ) - if not ifcopenshell.api.alignment.add_zero_length_segment(file, layout, include_referent=False): - return # zero length segment not added, probably because it already exists + ifcopenshell.api.alignment.add_zero_length_segment(file, layout) curve = ifcopenshell.api.alignment.get_layout_curve(layout) - if curve: ifcopenshell.api.alignment.add_zero_length_segment(file, curve) - - segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(layout) - segment = segment_nest.RelatedObjects[-1] - alignment = ifcopenshell.api.alignment.get_alignment(layout) - station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment) - name = f"{_get_segment_start_point_label(segment,None)} ({ifcopenshell.util.alignment.station_as_string(file,station)})" - referent = ifcopenshell.api.alignment.add_stationing_referent(file, alignment, 0.0, station, name, segment) diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/_create_geometric_representation.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/_create_geometric_representation.py index 933ee3a470..53113aacd3 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/_create_geometric_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/_create_geometric_representation.py @@ -35,6 +35,8 @@ def _create_geometric_representation(file: ifcopenshell.file, alignment: entity_ 4) Vertical only (this occurs when horizontal is reused from a parent alignment) -> IfcGradientCurve 5) Vertical + Cant (this occurs when horizontal is reused from a parent alignment) -> IfcSegmentedReferenceCurve + This method creates the geometric representation entity and assigns it to the alignment, but does not populate the geometry of the representation. + :param alignment: The alignment for which the representation is being created :return: None """ @@ -43,13 +45,6 @@ def _create_geometric_representation(file: ifcopenshell.file, alignment: entity_ if not alignment.is_a(expected_type): raise TypeError(f"Expected {expected_type} but got {alignment.is_a()}") - placement = file.createIfcLocalPlacement( - PlacementRelTo=None, - RelativePlacement=file.createIfcAxis2Placement2D(Location=file.createIfcCartesianPoint(Coordinates=(0.0, 0.0))), - ) - - alignment.ObjectPlacement = placement - axis_geom_subcontext = ifcopenshell.api.alignment.get_axis_subcontext(file) layouts = ifcopenshell.api.alignment.get_alignment_layouts(alignment) @@ -126,7 +121,7 @@ def _create_geometric_representation(file: ifcopenshell.file, alignment: entity_ ifcopenshell.api.geometry.assign_representation(file, alignment, representation) for child_alignment in children: - child_alignment.ObjectPlacement = placement + child_alignment.ObjectPlacement = alignment.ObjectPlacement child_layouts = ifcopenshell.api.alignment.get_alignment_layouts(child_alignment) if len(child_layouts) == 1: assert child_layouts[0].is_a("IfcAlignmentVertical") diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/_get_segment_endpoint.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/_get_segment_endpoint.py new file mode 100644 index 0000000000..5db57d481e --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/_get_segment_endpoint.py @@ -0,0 +1,89 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 Thomas Krijnen +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 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 +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + + +import ifcopenshell.api.alignment +import ifcopenshell.geom +from ifcopenshell import entity_instance, ifcopenshell_wrapper +from ifcopenshell.api.alignment._map_alignment_segment import _map_alignment_segment +from typing import Union +import math +import numpy as np + + +def _get_segment_endpoint(file: ifcopenshell.file, segment: entity_instance) -> Union[np.array, None]: + """ + Computes the 4x4 matrix for a segment end point. The segment can be an IfcAlignmentSegment + or IfcCurveSegment + """ + + expected_types = ["IfcAlignmentSegment", "IfcCurveSegment"] + if not segment.is_a() in expected_types: + raise TypeError( + f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received {segment.is_a()}" + ) + + file.begin_transaction() # use a transaction so we can discard any temporary IFC entities created + + curve_segment = segment + if segment.is_a("IfcAlignmentSegment"): + layout = ifcopenshell.api.alignment.get_layout(segment) + mapped_segments = _map_alignment_segment(file, layout, segment) + curve_segment = mapped_segments[0] if mapped_segments[1] == None else mapped_segments[1] + + # Inside of the IfcOpenShell C++ implementation where the IfcCurveSegment calculations occur, + # the composite curve owning the segment is evaluated to determine if a horizontal, vertical, or cant segment is being evaluated. + # This is necessary to determine how the end point of the curve segment is calculated. + # A temporary curve segment has been created and it needs to be associated with the correct composite curve for the end point to be calculated correctly. + # Inside the C++ implementation, if a composite curve isn't associated with the segment the segment is assumed to be horizontal. For this reason + # a temporary IfcCompositeCurve for horizontal segments doesn't need to be created. + if layout.is_a("IfcAlignmentVertical"): + gc = file.createIfcGradientCurve(Segments=[curve_segment]) + elif layout.is_a("IfcAlignmentCant"): + # The evaluation of cant segments depend on the start conditions of the next segment. In the absense of a next segment the + # optional EndPoint is used. Since a tempoaryar IfcSegmentReferenceCurve is being used, there is not a next segment. + # For this reason the EndPoint must be created from the design parameters of the sementic segment definiton. + Dsl = segment.DesignParameters.StartCantLeft + Dsr = segment.DesignParameters.StartCantRight + Del = segment.DesignParameters.EndCantLeft if segment.DesignParameters.EndCantLeft != None else Dsl + Der = segment.DesignParameters.EndCantRight if segment.DesignParameters.EndCantRight != None else Dsr + cant = Der - Del + rh = layout.RailHeadDistance + Ay = cant / rh + Az = math.sqrt(rh**2 - cant**2) / rh + + src = file.createIfcSegmentedReferenceCurve( + Segments=[curve_segment], + EndPoint=file.createIfcAxis2Placement3D( + Location=file.createIfcCartesianPoint((segment.DesignParameters.StartDistAlong, 0.5 * cant, 0.0)), + RefDirection=file.createIfcDirection((1.0, 0.0, 0.0)), + Axis=file.createIfcDirection((0.0, Ay, Az)), + ), + ) + + settings = ifcopenshell.geom.settings() + + segment_fn = ifcopenshell_wrapper.map_shape(settings, curve_segment) + segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn) + x = segment_fn.end() + e = segment_evaluator.evaluate(x) + end = np.array(e) + + file.discard_transaction() + + return end diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/_map_alignment_cant_segment.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/_map_alignment_cant_segment.py index d7ae7526b4..2fb11370d8 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/_map_alignment_cant_segment.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/_map_alignment_cant_segment.py @@ -24,10 +24,12 @@ from ifcopenshell import entity_instance def _get_axis(file: ifcopenshell.file, Ds: float, rail_head_distance: float) -> entity_instance: - Dy = rail_head_distance - Dz = 2 * Ds - D = math.sqrt(Dy * Dy + Dz * Dz) - return file.createIfcDirection((0.0, Dz / D, Dy / D)) + # solves the ratio right triangle legs to hypotenous + # Dh^2 = Dy^2 + Dz^2 + Dh = rail_head_distance # hypotenous + Dy = 2 * Ds # horizontal leg + Dz = math.sqrt(Dh * Dh - Dy * Dy) # vertical leg + return file.createIfcDirection((0.0, Dy / Dh, Dz / Dh)) def _map_constant_cant( @@ -54,7 +56,7 @@ def _map_constant_cant( Transition=transition, Placement=file.createIfcAxis2Placement3D( Location=start_point, - Axis=_get_axis(file, Ds, rail_head_distance), + Axis=_get_axis(file, 0.5 * (Dsr - Dsl), rail_head_distance), RefDirection=file.createIfcDirection((math.cos(start_direction), math.sin(start_direction), 0.0)), ), SegmentStart=file.createIfcLengthMeasure(0.0), diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/_map_alignment_segment.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/_map_alignment_segment.py new file mode 100644 index 0000000000..117bfe10f7 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/_map_alignment_segment.py @@ -0,0 +1,49 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 Thomas Krijnen +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 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 +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +from collections.abc import Sequence + +import ifcopenshell +from ifcopenshell import entity_instance + +from ifcopenshell.api.alignment._map_alignment_cant_segment import ( + _map_alignment_cant_segment, +) +from ifcopenshell.api.alignment._map_alignment_horizontal_segment import ( + _map_alignment_horizontal_segment, +) +from ifcopenshell.api.alignment._map_alignment_vertical_segment import ( + _map_alignment_vertical_segment, +) + + +def _map_alignment_segment( + file: ifcopenshell.file, layout: entity_instance, segment: entity_instance +) -> Sequence[entity_instance]: + """ + Maps an IfcAlignmentSegment to its corresponding IfcCurveSegment(s) in the geometric representation. + The mapping is done based on the layout type and segment type. + """ + if layout.is_a("IfcAlignmentHorizontal"): + mapped_segments = _map_alignment_horizontal_segment(file, segment) + elif layout.is_a("IfcAlignmentVertical"): + mapped_segments = _map_alignment_vertical_segment(file, segment) + else: + mapped_segments = _map_alignment_cant_segment(file, segment, layout.RailHeadDistance) + + return mapped_segments diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/_update_zero_length_segment_placement.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/_update_zero_length_segment_placement.py new file mode 100644 index 0000000000..eb1d57f4e5 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/_update_zero_length_segment_placement.py @@ -0,0 +1,71 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 Thomas Krijnen +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 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 +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import numpy as np + +import ifcopenshell +import math +import ifcopenshell.api.alignment +import ifcopenshell.util.unit +from ifcopenshell import entity_instance + + +def _update_zero_length_segment_placement( + file: ifcopenshell.file, zero_length_segment: entity_instance, placement: np.array +) -> None: + """ + Updates the placement of a zero length segment (i.e. a segment with identical start and end point) based on a 4x4 placement matrix. + The zero_length_segment can be an IfcAlignmentSegment or IfcCurveSegment. + """ + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + x = float(placement[0, 3]) / unit_scale + y = float(placement[1, 3]) / unit_scale + z = float(placement[2, 3]) / unit_scale + Rdx = float(placement[0, 0]) + Rdy = float(placement[1, 0]) + Rdz = float(placement[2, 0]) + Adx = float(placement[0, 2]) + Ady = float(placement[1, 2]) + Adz = float(placement[2, 2]) + + if zero_length_segment.is_a("IfcCurveSegment"): + if zero_length_segment.Placement.is_a("IfcAxis2Placement2D"): + zero_length_segment.Placement.Location.Coordinates = (x, y) + zero_length_segment.Placement.RefDirection.DirectionRatios = (Rdx, Rdy) + else: + zero_length_segment.Placement.Location.Coordinates = (x, y, z) + zero_length_segment.Placement.RefDirection.DirectionRatios = (Rdx, Rdy, Rdz) + zero_length_segment.Placement.Axis.DirectionRatios = (Adx, Ady, Adz) + elif zero_length_segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment"): + zero_length_segment.DesignParameters.StartPoint.Coordinates = (x, y) + zero_length_segment.DesignParameters.StartDirection = math.atan(Rdy / Rdx) + elif zero_length_segment.DesignParameters.is_a("IfcAlignmentVerticalSegment"): + zero_length_segment.DesignParameters.StartDistAlong = x + zero_length_segment.DesignParameters.StartHeight = y + zero_length_segment.DesignParameters.StartGradient = Rdy / Rdx + zero_length_segment.DesignParameters.EndGradient = zero_length_segment.DesignParameters.StartGradient + else: + slope = Ady / math.sqrt(Ady**2 + Adz**2) + layout = ifcopenshell.api.alignment.get_layout(zero_length_segment) + railhead = layout.RailHeadDistance + + zero_length_segment.DesignParameters.StartDistAlong = x + zero_length_segment.DesignParameters.StartCantLeft = y - slope * railhead / 2.0 + zero_length_segment.DesignParameters.StartCantRight = y + slope * railhead / 2.0 + zero_length_segment.DesignParameters.EndCantLeft = zero_length_segment.DesignParameters.StartCantLeft + zero_length_segment.DesignParameters.EndCantRight = zero_length_segment.DesignParameters.StartCantRight diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/add_stationing_referent.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_stationing_referent.py index 5d17c04b7b..32f88ef501 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/add_stationing_referent.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_stationing_referent.py @@ -20,6 +20,7 @@ import numpy as np import ifcopenshell import ifcopenshell.api.alignment +from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position import ifcopenshell.api.pset import ifcopenshell.geom import ifcopenshell.guid @@ -58,7 +59,7 @@ def add_stationing_referent( object_placement = None representation = None - if basis_curve: + if basis_curve and basis_curve.is_a("IfcCompositeCurve") and 0 < len(basis_curve.Segments): object_placement = file.createIfcLinearPlacement( RelativePlacement=file.createIfcAxis2PlacementLinear( Location=file.createIfcPointByDistanceExpression( @@ -71,54 +72,13 @@ def add_stationing_referent( ), ) - is_valid_curve = True - if basis_curve.is_a("IfcCompositeCurve") and len(basis_curve.Segments) == 0: - is_valid_curve = False - if basis_curve.is_a("IfcPolyline") and len(basis_curve.Points) < 2: - is_valid_curve = False - elif basis_curve.is_a("IfcIndexedPolyCurve") and len(basis_curve.Points.CoordList) < 2: - is_valid_curve = False - - if is_valid_curve: - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) - - settings = ifcopenshell.geom.settings() - fn = ifcopenshell_wrapper.map_shape(settings, basis_curve) - - if basis_curve.is_a("IfcPolyline") or basis_curve.is_a("IfcIndexedPolyCurve"): - fn = ifcopenshell_wrapper.convert_loop_to_function_item(fn) - - evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, fn) - - p = evaluator.evaluate(distance_along * unit_scale) - p = np.array(p) - - x = float(p[0, 3]) / unit_scale - y = float(p[1, 3]) / unit_scale - z = float(p[2, 3]) / unit_scale - - rx = float(p[0, 0]) - ry = float(p[1, 0]) - rz = float(p[2, 0]) - - ax = float(p[0, 2]) - ay = float(p[1, 2]) - az = float(p[2, 2]) - else: - x = 0.0 - y = 0.0 - z = 0.0 - rx = 1.0 - ry = 0.0 - rz = 0.0 - ax = 0.0 - ay = 0.0 - az = 1.0 - - object_placement.CartesianPosition = file.createIfcAxis2Placement3D( - Location=file.createIfcCartesianPoint((x, y, z)), - Axis=file.createIfcDirection((ax, ay, az)), - RefDirection=file.createIfcDirection((rx, ry, rz)), + update_fallback_position(file, object_placement) + else: + object_placement = file.createIfcLocalPlacement( + PlacementRelTo=None, + RelativePlacement=file.createIfcAxis2Placement2D( + Location=file.createIfcCartesianPoint(alignment.ObjectPlacement.RelativePlacement.Location.Coordinates) + ), ) # this commented out code is what you would do to add a geometric representation of the referent @@ -144,7 +104,12 @@ def add_stationing_referent( ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties={"Station": station}) nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment) - nest.RelatedObjects += (referent,) + if nest is None: + nest = file.createIfcRelNests( + GlobalId=ifcopenshell.guid.new(), RelatingObject=alignment, RelatedObjects=(referent,) + ) + else: + nest.RelatedObjects += (referent,) nest.RelatedObjects = sorted( nest.RelatedObjects, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station") diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/add_zero_length_segment.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_zero_length_segment.py index 8a255cdb69..e5f9b4bd8a 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/add_zero_length_segment.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_zero_length_segment.py @@ -18,14 +18,12 @@ import math -import numpy as np - import ifcopenshell import ifcopenshell.api.alignment +from ifcopenshell.api.alignment._get_segment_endpoint import _get_segment_endpoint +from ifcopenshell.api.alignment._update_zero_length_segment_placement import _update_zero_length_segment_placement import ifcopenshell.api.nest -import ifcopenshell.geom import ifcopenshell.ifcopenshell_wrapper as wrapper -import ifcopenshell.util.alignment import ifcopenshell.util.unit from ifcopenshell import entity_instance from ifcopenshell.api.alignment._get_segment_start_point_label import ( @@ -42,14 +40,13 @@ from ifcopenshell.api.alignment._update_curve_segment_transition_code import ( ) -def add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance, include_referent: bool = True) -> bool: +def add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance) -> bool: """ Adds a zero length segment to the end of a layout. If the layout already has a zero length segment, nothing is changed. :param layout: An IfcAlignmentHorizontal, IfcAlignmentVertical, IfcAlignmentCant, IfcCompositeCurve, IfcGradientCurve, IfcSegmentedReferenceCurve - :param include_referent: If True, an IfcReferent representing the ending point of the layout is included for IfcLinearElement layouts (i.e. business logic) :return: True if segment is added """ @@ -74,28 +71,6 @@ def add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance, in return False if layout.is_a("IfcCompositeCurve") or layout.is_a("IfcGradientCurve") or layout.is_a("IfcSegmentedReferenceCurve"): - x = 0.0 - y = 0.0 - dx = 1.0 - dy = 0.0 - segment_start = 0.0 - - last_segment = None - if layout.Segments and 0 < len(layout.Segments): - # If there are segments, get the last segment and compute the end point and tangent direction - # because this becomes of placement of the zero length segment - last_segment = layout.Segments[-1] - settings = ifcopenshell.geom.settings() - fn = wrapper.map_shape(settings, last_segment) - eval = wrapper.function_item_evaluator(settings, fn) - e = np.array(eval.evaluate(fn.end())) - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) - e[:3, 3] /= unit_scale - x = float(e[0, 3]) - y = float(e[1, 3]) - dx = float(e[0, 0]) - dy = float(e[1, 0]) - parent_curve = file.createIfcLine( Pnt=file.createIfcCartesianPoint(Coordinates=((0.0, 0.0))), Dir=file.createIfcVector( @@ -103,22 +78,36 @@ def add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance, in Magnitude=1.0, ), ) + if layout.is_a("IfcSegmentedReferenceCurve"): + placement = file.createIfcAxis2Placement3D( + Location=file.createIfcCartesianPoint((0.0, 0.0, 0.0)), + RefDirection=file.createIfcDirection((1.0, 0.0, 0.0)), + Axis=file.createIfcDirection((0.0, 0.0, 1.0)), + ) + else: + placement = file.createIfcAxis2Placement2D( + Location=file.createIfcCartesianPoint((0.0, 0.0)), + RefDirection=file.createIfcDirection((1.0, 0.0)), + ) + zero_length_curve_segment = file.createIfcCurveSegment( Transition="DISCONTINUOUS", - Placement=file.createIfcAxis2Placement2D( - Location=file.createIfcCartesianPoint((x, y)), - RefDirection=file.createIfcDirection((dx, dy)), - ), + Placement=placement, SegmentStart=file.createIfcLengthMeasure(0.0), SegmentLength=file.createIfcLengthMeasure(0.0), ParentCurve=parent_curve, ) - layout.Segments += (zero_length_curve_segment,) - - if last_segment: + if layout.Segments and 0 < len(layout.Segments): + # If there are segments, get the last segment and compute the end point and tangent direction + # because this becomes of placement of the zero length segment + last_segment = layout.Segments[-1] + end_point = _get_segment_endpoint(file, last_segment) + _update_zero_length_segment_placement(file, zero_length_curve_segment, end_point) _update_curve_segment_transition_code(last_segment, zero_length_curve_segment) + layout.Segments += (zero_length_curve_segment,) + # add zero length segments to base curves if layout.is_a("IfcSegmentedReferenceCurve"): ifcopenshell.api.alignment.add_zero_length_segment(file, layout.BaseCurve) @@ -139,22 +128,14 @@ def add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance, in break if last_segment: - file.begin_transaction() # use a transaction so we can discard any temporary IFC entities created + e = _get_segment_endpoint(file, last_segment) - settings = ifcopenshell.geom.settings() - mapped_segments = _map_alignment_horizontal_segment(file, last_segment) - geometry_segment = mapped_segments[0] if mapped_segments[1] == None else mapped_segments[1] - fn = wrapper.map_shape(settings, geometry_segment) - eval = wrapper.function_item_evaluator(settings, fn) - e = np.array(eval.evaluate(fn.end())) unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) x = float(e[0, 3]) / unit_scale y = float(e[1, 3]) / unit_scale dx = float(e[0, 0]) dy = float(e[1, 0]) - file.discard_transaction() - angle_unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file, "PLANEANGLEUNIT") design_parameters = file.createIfcAlignmentHorizontalSegment( StartPoint=file.createIfcCartesianPoint((x, y)), @@ -178,22 +159,14 @@ def add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance, in break if last_segment: - file.begin_transaction() last_segment_dist_along = ( last_segment.DesignParameters.StartDistAlong + last_segment.DesignParameters.HorizontalLength ) last_segment_end_gradient = last_segment.DesignParameters.EndGradient - settings = ifcopenshell.geom.settings() - mapped_segments = _map_alignment_vertical_segment(file, last_segment) - geometry_segment = mapped_segments[0] if mapped_segments[1] == None else mapped_segments[1] - fn = wrapper.map_shape(settings, geometry_segment) - eval = wrapper.function_item_evaluator(settings, fn) - e = np.array(eval.evaluate(fn.end())) + e = _get_segment_endpoint(file, last_segment) unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) last_segment_height = float(e[1, 3]) / unit_scale - file.discard_transaction() - design_parameters = file.createIfcAlignmentVerticalSegment( StartDistAlong=last_segment_dist_along, HorizontalLength=0.0, @@ -240,13 +213,4 @@ def add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance, in ifcopenshell.api.nest.assign_object(file, related_objects=[zero_length_curve_segment], relating_object=layout) - if include_referent: - alignment = ifcopenshell.api.alignment.get_alignment(layout) - station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment) - name = f"{_get_segment_start_point_label(zero_length_curve_segment,None)} ({ifcopenshell.util.alignment.station_as_string(file,station)})" - referent = ifcopenshell.api.alignment.add_stationing_referent( - file, alignment, 0.0, station, name, zero_length_curve_segment - ) - referent.Description = f"Positions zero length segment {zero_length_curve_segment.id()}" - return True diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/create.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/create.py index d2682aaad1..0077f672e8 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/create.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/create.py @@ -63,6 +63,12 @@ def create( alignment = file.createIfcAlignment( GlobalId=ifcopenshell.guid.new(), Name=name, + ObjectPlacement=file.createIfcLocalPlacement( + PlacementRelTo=None, + RelativePlacement=file.createIfcAxis2Placement2D( + Location=file.createIfcCartesianPoint(Coordinates=(0.0, 0.0)) + ), + ), ) alignment_layouts = [] @@ -80,10 +86,10 @@ def create( if include_geometry: _create_geometric_representation(file, alignment) - name = ifcopenshell.util.alignment.station_as_string(file, start_station) - referent = ifcopenshell.api.alignment.add_stationing_referent( - file, alignment, 0.0, start_station, name, alignment - ) + referent_name = ifcopenshell.util.alignment.station_as_string(file, start_station) + referent = ifcopenshell.api.alignment.add_stationing_referent( + file, alignment, 0.0, start_station, referent_name, alignment + ) for layout in alignment_layouts: _add_zero_length_segment(file, layout) diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_layout_segment.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_layout_segment.py index 5d1b5452f1..433f220754 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_layout_segment.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_layout_segment.py @@ -53,35 +53,8 @@ def create_layout_segment( # create the segment and add it to the layout. segment = file.createIfcAlignmentSegment(GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters) - _add_segment_to_layout(file, layout, segment) # adds to layout and geometric representation + end = _add_segment_to_layout( + file, layout, segment + ) # adds to layout and geometric representation (if present, also updates zero length segment position) - # compute the 4x4 matrix at the end of the segment so this information can be - # returned and used when defining the next segment - alignment = ifcopenshell.api.alignment.get_alignment(layout) - curve = ifcopenshell.api.alignment.get_curve(alignment) - - if curve: - if layout.is_a("IfcAlignmentHorizontal"): - if curve.is_a("IfcGradientCurve"): - curve = curve.BaseCurve - elif curve.is_a("IfcSegmentedReferenceCurve"): - curve = ( - curve.BaseCurve.BaseCurve - ) # layout is horizontal and curve is segmented ref ... we want the curve's base curve - elif layout.is_a("IfcAlignmentVertical"): - if curve.is_a("IfcSegmentedReferenceCurve"): - curve = curve.BaseCurve - - # the new segment is two from the end... the end segment is zero length - curve_segment = curve.Segments[-2] - - settings = ifcopenshell.geom.settings() - - segment_fn = ifcopenshell_wrapper.map_shape(settings, curve_segment) - segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn) - e = segment_evaluator.evaluate(segment_fn.end()) - end = np.array(e) - - return end - else: - return None + return end diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_representation.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_representation.py index 896108367f..2ce48fd965 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_representation.py @@ -23,6 +23,7 @@ from ifcopenshell.api.alignment._add_segment_to_curve import _add_segment_to_cur from ifcopenshell.api.alignment._create_geometric_representation import ( _create_geometric_representation, ) +from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position def create_representation( @@ -34,8 +35,13 @@ def create_representation( This function is intended to be used when a model has only the semantic definition of an alignment and you want to add the geometric representation. - If the alignments are complete, it is recommended that add_zero_length_segment is called after this method to ensure - the proper structure of the semantic and geometric definitions of the alignment + If the alignments are complete, it is recommended that add_zero_length_segment is called before this method to ensure + the proper structure of the semantic and geometric definitions of the alignment. + + It is presumed that the alignment does not have any geometric representation. However, if the alignment has stationing defined, + the referent defining the stationing is not related to the alignment geometry (it can't be because the geometry doesn't exist yet). + When the geometric representation is created, the referent is updated to have an IfcLinearPlacement that references the basis curve geometry. + This function assumes the referent defines the stationing at the start of the alignment, and therefore sets the IfcLinearPlacement.RelativePlacement.Location.DistanceAlong to 0.0. :param alignment: The alignment to create the representation. """ @@ -51,6 +57,40 @@ def create_representation( layouts = ifcopenshell.api.alignment.get_alignment_layouts(alignment) for layout in layouts: curve = ifcopenshell.api.alignment.get_layout_curve(layout) + layout_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(layout) for segment in layout_nest.RelatedObjects: _add_segment_to_curve(file, segment, curve) + + # if the alignment is created without geometry it's stationing referent isn't related to the alignment geometry. + # the stationing referent needs to be updated to have an IfcLinearPlacement that references the basis curve geometry + referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment) + if ( + referent_nest + and 0 < len(referent_nest.RelatedObjects) + and referent_nest.RelatedObjects[0].ObjectPlacement + and not referent_nest.RelatedObjects[0].ObjectPlacement.is_a("IfcLinearPlacement") + ): + basis_curve = ifcopenshell.api.alignment.get_basis_curve(alignment) + + if referent_nest.RelatedObjects[0].ObjectPlacement: + if referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.Location: + file.remove(referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.Location) + if referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.RefDirection: + file.remove(referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.RefDirection) + file.remove(referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement) + file.remove(referent_nest.RelatedObjects[0].ObjectPlacement) + + lp = file.createIfcLinearPlacement( + RelativePlacement=file.createIfcAxis2PlacementLinear( + Location=file.createIfcPointByDistanceExpression( + DistanceAlong=file.createIfcLengthMeasure(0.0), + OffsetLateral=None, + OffsetVertical=None, + OffsetLongitudinal=None, + BasisCurve=basis_curve, + ) + ) + ) + update_fallback_position(file, lp) + referent_nest.RelatedObjects[0].ObjectPlacement = lp diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/get_curve_segment.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/get_curve_segment.py new file mode 100644 index 0000000000..a9b9308d67 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/get_curve_segment.py @@ -0,0 +1,51 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 Thomas Krijnen +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 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 +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +from collections.abc import Sequence + +from ifcopenshell import entity_instance + +import ifcopenshell.api.alignment + +from ifcopenshell.api.alignment.get_mapped_segments import _get_curve_segment_count + + +def get_curve_segment(layout: entity_instance, segment: entity_instance) -> entity_instance: + """ + Returns the IfcCurveSegment associated with the given alignment segment. If the curve segment does not exist, None is returned. + + Example: + + .. code:: python + + horizontal = model.by_type("IfcAlignmentHorizontal")[0] + curve_segment = ifcopenshell.api.alignment.get_curve_segment(horizontal, alignment_segment) + """ + index = 0 + segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(layout) + for related_object in segment_nest.RelatedObjects: + if related_object == segment: + break + n = _get_curve_segment_count(related_object) + index += n + + curve = ifcopenshell.api.alignment.get_layout_curve(layout) + if curve and index < len(curve.Segments): + return curve.Segments[index] + else: + return None diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/get_layout.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/get_layout.py new file mode 100644 index 0000000000..d6e615b30d --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/get_layout.py @@ -0,0 +1,34 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 Thomas Krijnen +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 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 +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +from ifcopenshell import entity_instance + + +def get_layout(segment: entity_instance) -> entity_instance: + """ + Retrieves the layout to which an alignment segment belongs. + """ + if not segment.is_a("IfcAlignmentSegment"): + raise TypeError(f"Expected entity type to be IfcAlignmentSegment, instead received {segment.is_a()}") + + layout = None + nests = segment.Nests + if nests: + layout = nests[0].RelatingObject + + return layout diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/get_referent_nest.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/get_referent_nest.py index b57b787e38..b67b9589de 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/get_referent_nest.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/get_referent_nest.py @@ -22,11 +22,11 @@ from ifcopenshell import entity_instance def get_referent_nest(file: ifcopenshell.file, alignment: entity_instance) -> entity_instance: """ - Searches for the IfcRelNest that contains IfcReferent. If one is not found, a empty IfcRelNests is created. + Searches for the IfcRelNest that contains IfcReferent. :param file: :param alignment: The IfcAlignment which hosts IfcReferent - :return: Returns the IfcRelNests. + :return: Returns the IfcRelNests or None """ if not alignment.is_a("IfcAlignment"): raise TypeError(f"Expected IfcAlignment, instead received {alignment.is_a()}") @@ -36,5 +36,4 @@ def get_referent_nest(file: ifcopenshell.file, alignment: entity_instance) -> en if related_object.is_a("IfcReferent"): return nest - nest = file.createIfcRelNests(GlobalId=ifcopenshell.guid.new(), RelatingObject=alignment, RelatedObjects=[]) - return nest + return None diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/update_end_point.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/update_end_point.py new file mode 100644 index 0000000000..0349a99783 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/update_end_point.py @@ -0,0 +1,90 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 Thomas Krijnen +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 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 +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import numpy as np + +import ifcopenshell +import ifcopenshell.util.placement +from ifcopenshell import entity_instance + + +def update_end_point(file: ifcopenshell.file, curve: entity_instance): + """ + Updates the IfcGradientCurve.EndPoint and IfcSegmentedReferenceCurve.EndPoint. + + If the curve does not have a zero length segment, one is added. The EndPoint is then updated to match the placement of the zero length segment. + + :param curve: The gradient curve or segmented reference curve + :return: None + """ + expected_types = ["IfcGradientCurve", "IfcSegmentedReferenceCurve"] + if not curve.is_a() in expected_types: + raise TypeError( + f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received '{curve.is_a()}" + ) + + if not ifcopenshell.api.alignment.has_zero_length_segment(curve): + ifcopenshell.api.alignment.add_zero_length_segment(file, curve) + + zero_length_segment = curve.Segments[-1] + + if not curve.EndPoint: + if curve.is_a("IfcGradientCurve"): + curve.EndPoint = file.createIfcAxis2Placement2D( + Location=file.createIfcCartesianPoint((0.0, 0.0)), + RefDirection=file.createIfcDirection((1.0, 0.0)), + ) + else: + curve.EndPoint = file.createIfcAxis2Placement3D( + Location=file.createIfcCartesianPoint((0.0, 0.0, 0.0)), + RefDirection=file.createIfcDirection((1.0, 0.0, 0.0)), + Axis=file.createIfcDirection((0.0, 0.0, 1.0)), + ) + + p = np.array(ifcopenshell.util.placement.get_axis2placement(zero_length_segment.Placement)) + + x = float(p[0, 3]) + y = float(p[1, 3]) + z = float(p[2, 3]) + + rx = float(p[0, 0]) + ry = float(p[1, 0]) + rz = float(p[2, 0]) + + ax = float(p[0, 2]) + ay = float(p[1, 2]) + az = float(p[2, 2]) + + if curve.is_a("IfcGradientCurve"): + curve.EndPoint.Location.Coordinates = (x, y) + + if not curve.EndPoint.RefDirection: + curve.EndPoint.RefDirection = file.createIfcDirection((1.0, 0.0)) + + curve.EndPoint.RefDirection.DirectionRatios = (rx, ry) + else: + curve.EndPoint.Location.Coordinates = (x, y, z) + + if not curve.EndPoint.RefDirection: + curve.EndPoint.RefDirection = file.createIfcDirection((1.0, 0.0, 0.0)) + + if not curve.EndPoint.Axis: + curve.EndPoint.Axis = file.createIfcDirection((0.0, 0.0, 1.0)) + + curve.EndPoint.RefDirection.DirectionRatios = (rx, ry, rz) + curve.EndPoint.Axis.DirectionRatios = (ax, ay, az) diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/update_fallback_position.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/update_fallback_position.py index 3cd4c05907..ad69bcf401 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/update_fallback_position.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/update_fallback_position.py @@ -34,7 +34,7 @@ def update_fallback_position(file: ifcopenshell.file, lp: entity_instance): """ if not lp.CartesianPosition: - lp.CartesianPosition = file.createIfcAxis2Placement3D(Location=file.createIfcCartesianPoint((0.0, 0.0))) + lp.CartesianPosition = file.createIfcAxis2Placement3D(Location=file.createIfcCartesianPoint((0.0, 0.0, 0.0))) p = np.array(ifcopenshell.util.placement.get_axis2placement(lp.RelativePlacement)) diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/util.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/util.py index ee29045eb6..2a64f0bfbb 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/util.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/util.py @@ -60,7 +60,7 @@ def evaluate_segment(segment: entity_instance, dist_along: float) -> np.ndarray: segment_type = segment.is_a().upper() if not segment_type in supported_segment_types: raise NotImplementedError(f"Expected entity type 'IFCCURVESEGMENT', got '{segment_type}") - if dist_along > segment.SegmentLength: + if dist_along > abs(segment.SegmentLength.wrappedValue): raise ValueError(f"Provided value {dist_along=} is beyond the end of the segment ({segment.SegmentLength}).") s = ifcopenshell.geom.settings() diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_item.py b/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_item.py index ce1aa5545e..9b90cce2e8 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_item.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_item.py @@ -51,7 +51,7 @@ def remove_cost_item(file: ifcopenshell.file, cost_item: ifcopenshell.entity_ins if history: ifcopenshell.util.element.remove_deep2(file, history) elif inverse.is_a("IfcRelAssignsToControl"): - if len(inverse.RelatedObjects) >= 2 or inverse.RelatingControl == cost_item: + if len(inverse.RelatedObjects) >= 2: continue history = inverse.OwnerHistory file.remove(inverse) diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py index d845f4dc83..93a5b8da78 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py @@ -33,7 +33,20 @@ from .add_door_representation import add_door_representation from .add_footprint_representation import add_footprint_representation from .add_mesh_representation import add_mesh_representation from .add_profile_representation import add_profile_representation -from .add_railing_representation import add_railing_representation + +# add_railing_representation is the pilot for a "pure-compute + IFC-wrap" split: +# compute_wall_mounted_handrail_geometry returns a dataclass with the raw geometry, +# add_railing_representation wraps it into an IfcShapeRepresentation. The split lets +# downstream consumers (Blender gizmo previews, etc.) drive the same math without +# round-tripping through an IFC file. Future add_X_representation work is encouraged +# to follow the same shape — sibling compute_X_geometry function + thin IFC wrapper. +from .add_railing_representation import ( + RailingSupport, + TERMINAL_TYPE, + WallMountedHandrailGeometry, + add_railing_representation, + compute_wall_mounted_handrail_geometry, +) try: from .add_representation import add_representation @@ -72,8 +85,12 @@ __all__ = [ "add_door_representation", "add_footprint_representation", "add_mesh_representation", + "RailingSupport", + "TERMINAL_TYPE", + "WallMountedHandrailGeometry", "add_profile_representation", "add_railing_representation", + "compute_wall_mounted_handrail_geometry", "add_representation", "add_shape_aspect", "add_slab_representation", diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_door_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_door_representation.py index a4460ce984..6174d9d2d7 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_door_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_door_representation.py @@ -28,6 +28,7 @@ import ifcopenshell.api.geometry import ifcopenshell.util.unit from ifcopenshell.api.geometry.add_window_representation import create_ifc_window from ifcopenshell.util.shape_builder import ShapeBuilder, V +from ifcopenshell.util.unit import mm_to_m as mm DOOR_TYPE = Literal[ "SINGLE_SWING_LEFT", @@ -43,11 +44,6 @@ DOOR_TYPE = Literal[ SUPPORTED_DOOR_TYPES = get_args(DOOR_TYPE) -def mm(x: float) -> float: - """mm to meters shortcut for readability""" - return x / 1000 - - def create_ifc_door_lining( builder: ShapeBuilder, size: np.ndarray, thickness: Union[list[float], float], position: Optional[np.ndarray] = None ) -> ifcopenshell.entity_instance: diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_railing_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_railing_representation.py index a3af58dfbf..dea9dba023 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_railing_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_railing_representation.py @@ -16,18 +16,21 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . +from dataclasses import dataclass, field from math import cos, pi, radians, sin, tan -from typing import Any, Literal, Optional +from typing import Callable, Literal, Optional import numpy as np -from typing_extensions import assert_never import ifcopenshell.util.unit from ifcopenshell.util.shape_builder import ( + NP_XY, + NP_YX, + NP_Z, + PRECISION, SequenceOfVectors, ShapeBuilder, V, - is_x, np_angle, np_angle_signed, np_intersect_line_line, @@ -36,12 +39,7 @@ from ifcopenshell.util.shape_builder import ( np_normalized, np_to_3d, ) - - -def mm(x: float) -> float: - """mm to meters shortcut for readability""" - return x / 1000 - +from ifcopenshell.util.unit import mm_to_m as mm TERMINAL_TYPE = Literal[ "180", @@ -49,15 +47,524 @@ TERMINAL_TYPE = Literal[ "TO_WALL", "TO_FLOOR", "TO_END_POST_AND_FLOOR", + "NONE", ] +# Geometric design constants for the WALL_MOUNTED_HANDRAIL railing type (millimetres). +TERMINAL_RADIUS_MM = 150 +HANDRAIL_FILLET_RADIUS_MM = 100 +SUPPORT_ARC_RADIUS_MM = 10 +SUPPORT_DISK_DEPTH_MM = 20 + +# Default parameter values for ``add_railing_representation`` (millimetres). +DEFAULT_SUPPORT_SPACING_MM = 1000 +DEFAULT_RAILING_DIAMETER_MM = 50 +DEFAULT_CLEAR_WIDTH_MM = 40 +DEFAULT_HEIGHT_MM = 1000 + + +@dataclass(slots=True) +class RailingSupport: + """Pure-geometry description of a single wall-mount support. + + A support consists of: + + - A 3-point polyline (base at the handrail, mid-arc, floor end) + swept into a cylinder of radius ``arc_radius``. + - A short disk extrusion (wall-attachment plate) at the floor end. + + All values are in IFC project units. + """ + + arc_polyline: np.ndarray # shape (3, 3) + arc_radius: float + disk_position: np.ndarray # shape (3,) — equal to arc_polyline[-1] + disk_radius: float + disk_depth: float + disk_z_rotation: float # rotation around Z applied to the disk's "Y" extrude axis + + +@dataclass(slots=True) +class WallMountedHandrailGeometry: + """Pure-geometry description of a wall-mounted handrail. + + Decoupled from any IFC entity creation. The shared data structure is + consumed by the IFC-representation wrapper and by viewport-only previews + in authoring add-ons that need to update mesh state without mutating the + IFC file. + + All values are in IFC project units. + """ + + handrail_polyline: np.ndarray # shape (N, 3) + handrail_arc_point_indices: list[int] + handrail_radius: float + supports: list[RailingSupport] = field(default_factory=list) + + +_Z_DOWN = V(0, 0, -1) +_ARC_MIDDLE_POINT_COS = sin(radians(45)) + + +@dataclass(frozen=True) +class _RailingDims: + """Derived dimensions for a wall-mounted-handrail compute pass. + + All values are in IFC project units. + """ + + railing_radius: float + height_below_handrail: float + terminal_radius: float + fillet_radius: float + support_spacing: float + support_length: float + support_arc_radius: float + support_disk_radius: float + support_disk_depth: float + clear_width: float + cap_type: TERMINAL_TYPE + + +def _collinear(d0: np.ndarray, d1: np.ndarray) -> bool: + # Cross-product magnitude is linear near zero, so the test stays + # numerically stable for near-parallel unit vectors. The natural + # arccos(dot) formulation is not stable here: sub-ulp overshoot of + # dot past 1.0 returns NaN, which would silently break the fillet + # on straight subdivided edges. Anti-parallel vectors also collapse + # |d0 × d1| to 0 — and that "no usable turn" outcome is what the + # fillet caller wants, so we treat it as collinear too. + return bool(np.linalg.norm(np.cross(d0, d1)) < PRECISION) + + +def _get_fillet_points(v0: np.ndarray, v1: np.ndarray, v2: np.ndarray, radius: float) -> list[np.ndarray]: + """Fillet arc points between edges v0v1 and v1v2. + + Raises ``ZeroDivisionError`` / ``FloatingPointError`` (and may return + NaN/inf points) on numerically degenerate input — callers that may + receive degenerate input must guard. + """ + dir1 = np_normalized(v0 - v1) + dir2 = np_normalized(v2 - v1) + edge_angle = np_angle(dir1, dir2) + slide_distance = radius / tan(edge_angle / 2) + + fillet_v1co = v1 + (dir1 * slide_distance) + fillet_v2co = v1 + (dir2 * slide_distance) + + normal = np_normal([v0, v1, v2]) + center = np_intersect_line_line( + fillet_v1co, + fillet_v1co + np.cross(normal, dir1), + fillet_v2co, + fillet_v2co + np.cross(normal, dir2), + )[0] + + dir_ = np_normalized(np_lerp(fillet_v1co, fillet_v2co, 0.5) - center) + midpointco = center + dir_ * radius + return [fillet_v1co, midpointco, fillet_v2co] + + +def _make_support(point: np.ndarray, railing_direction: np.ndarray, dims: _RailingDims) -> RailingSupport: + """Build a pure-geometry support description from a point + railing direction.""" + ortho_dir = railing_direction[NP_YX] * (1, -1) + ortho_dir = np_normalized(np_to_3d(ortho_dir)) + arc_center = point + ortho_dir * dims.support_length + support_points = V( + [ + point, + arc_center - ortho_dir * dims.support_length * cos(pi / 4) + _Z_DOWN * dims.support_length * sin(pi / 4), + arc_center + _Z_DOWN * dims.support_length, + ] + ) + angle = np_angle_signed((0, 1), ortho_dir[NP_XY]) + return RailingSupport( + arc_polyline=support_points, + arc_radius=dims.support_arc_radius, + disk_position=support_points[-1], + disk_radius=dims.support_disk_radius, + disk_depth=dims.support_disk_depth, + disk_z_rotation=angle, + ) + + +def _add_arcs_on_turning_points( + base_points: np.ndarray, dims: _RailingDims, looped_path: bool +) -> tuple[np.ndarray, list[np.ndarray]]: + """Add 3-point fillet arcs on turning points of the railing path. + + Returns ``(polyline_with_arcs, arc_midpoints)``. + """ + arc_points: list[np.ndarray] = [] + if len(base_points) < 3: + return base_points, arc_points + + # looking for turning points by checking non-collinear edges + output_points: list[np.ndarray] = list(base_points[:1]) + prev_dir = np_normalized(base_points[1] - base_points[0]) + i = 1 + while i < len(base_points) - 1: + cur_dir = np_normalized(base_points[i + 1] - base_points[i]) + + # Treat NaN cur_dir (zero-length edge → np_normalized of zero) as + # collinear: a coincident path vertex carries no turn information, + # so the safest fallback is "stay on the previous direction". + cur_dir_is_nan = bool(np.any(np.isnan(cur_dir))) + + if cur_dir_is_nan or _collinear(cur_dir, prev_dir): + output_points.append(base_points[i]) + else: + # User-supplied railing paths can produce numerically degenerate + # turns (anti-parallel directions, nearly-collinear triangle, + # zero-length edges from coincident vertices). Falling back to a + # sharp turn at the original vertex keeps the rest of the + # polyline real-valued instead of poisoning it with NaN. + fillet_points: Optional[list[np.ndarray]] + try: + fillet_points = _get_fillet_points( + base_points[i - 1], base_points[i], base_points[i + 1], dims.fillet_radius + ) + except (ZeroDivisionError, FloatingPointError): + fillet_points = None + else: + if any(np.any(np.isnan(fp)) or np.any(np.isinf(fp)) for fp in fillet_points): + fillet_points = None + + if fillet_points is None: + output_points.append(base_points[i]) + else: + output_points.extend(fillet_points) + arc_points.append(fillet_points[1]) + + # Only advance prev_dir when cur_dir is well-defined — keeping a + # NaN prev_dir would cascade through every subsequent collinearity + # check. + if not cur_dir_is_nan: + prev_dir = cur_dir + i = i + 1 + + if looped_path: + output_points[0] = output_points[-1] + else: + output_points.append(base_points[-1]) + return V(output_points), arc_points + + +def _collect_supports(coords: np.ndarray, manual_supports: bool, dims: _RailingDims) -> list[RailingSupport]: + """Build the list of supports for the railing path.""" + supports: list[RailingSupport] = [] + # simplified_coords is a list of points that form non-collinear edges + simplified_coords: list[np.ndarray] = [coords[0]] + prev_dir = np_normalized(coords[1] - coords[0]) + + # iterating over each edge of the railing path + for i in range(1, len(coords) - 1): + cur_dir = np_normalized(coords[i + 1] - coords[i]) + + if not _collinear(cur_dir, prev_dir): + simplified_coords.append(coords[i]) + prev_dir = cur_dir + + # for manual supports each vertex on the railing path edge + # will be a point for a support + elif manual_supports: + supports.append(_make_support(coords[i], cur_dir, dims)) + + simplified_coords.append(coords[-1]) + + if manual_supports: + return supports + + # create automatic supports based on the support spacing + for i in range(len(simplified_coords) - 1): + v0, v1 = simplified_coords[i : i + 2] + edge = v1 - v0 + length: float = np.linalg.norm(edge) + edge_dir = np_normalized(edge) + n_supports, support_offset = divmod(length, dims.support_spacing) + n_supports = int(n_supports) + 1 + support_offset /= 2 + + start_position = v0 + support_offset * edge_dir + for support_i in range(n_supports): + support_position = start_position + support_i * dims.support_spacing * edge_dir + supports.append(_make_support(support_position, edge, dims)) + + return supports + + +# Per-cap-type builders. Each takes the cap-frame inputs (precomputed by the +# dispatcher) and returns ``(cap_coords, new_arc_points)``. The shared +# orientation flip and final ``np.vstack`` live in the dispatcher so the +# builders stay focused on the geometric shape of their cap. +_CapBuilder = Callable[ + [np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, "_RailingDims"], + tuple[list[np.ndarray], list[np.ndarray]], +] + + +def _cap_180( + railing_coords_for_cap: np.ndarray, + start_point: np.ndarray, + cap_dir: np.ndarray, + ortho_dir: np.ndarray, + local_z_down: np.ndarray, + dims: "_RailingDims", +) -> tuple[list[np.ndarray], list[np.ndarray]]: + arc_point = start_point + cap_dir * dims.terminal_radius + dims.terminal_radius * local_z_down + cap_coords = [arc_point, start_point + dims.terminal_radius * 2 * local_z_down] + return cap_coords, [arc_point] + + +def _cap_to_end_post( + railing_coords_for_cap: np.ndarray, + start_point: np.ndarray, + cap_dir: np.ndarray, + ortho_dir: np.ndarray, + local_z_down: np.ndarray, + dims: "_RailingDims", +) -> tuple[list[np.ndarray], list[np.ndarray]]: + arc_point = start_point + cap_dir * dims.terminal_radius + dims.terminal_radius * local_z_down + end_point = railing_coords_for_cap[-2].copy() + end_point[NP_Z] -= dims.terminal_radius * 2 + cap_coords = [arc_point, start_point + dims.terminal_radius * 2 * local_z_down, end_point] + return cap_coords, [arc_point] + + +def _cap_to_wall( + railing_coords_for_cap: np.ndarray, + start_point: np.ndarray, + cap_dir: np.ndarray, + ortho_dir: np.ndarray, + local_z_down: np.ndarray, + dims: "_RailingDims", +) -> tuple[list[np.ndarray], list[np.ndarray]]: + arc_point = ( + start_point + + cap_dir * dims.clear_width * _ARC_MIDDLE_POINT_COS + + ortho_dir * dims.clear_width * (1 - _ARC_MIDDLE_POINT_COS) + ) + cap_coords = [arc_point, start_point + ortho_dir * dims.clear_width + cap_dir * dims.clear_width] + return cap_coords, [arc_point] + + +def _cap_to_floor( + railing_coords_for_cap: np.ndarray, + start_point: np.ndarray, + cap_dir: np.ndarray, + ortho_dir: np.ndarray, + local_z_down: np.ndarray, + dims: "_RailingDims", +) -> tuple[list[np.ndarray], list[np.ndarray]]: + arc_point = ( + start_point + + cap_dir * dims.terminal_radius * _ARC_MIDDLE_POINT_COS + + _Z_DOWN * dims.terminal_radius * (1 - _ARC_MIDDLE_POINT_COS) + ) + arc_end = start_point + cap_dir * dims.terminal_radius + dims.terminal_radius * _Z_DOWN + cap_coords = [ + arc_point, + arc_end, + arc_end + _Z_DOWN * (dims.height_below_handrail - dims.terminal_radius), + ] + return cap_coords, [arc_point] + + +def _cap_to_end_post_and_floor( + railing_coords_for_cap: np.ndarray, + start_point: np.ndarray, + cap_dir: np.ndarray, + ortho_dir: np.ndarray, + local_z_down: np.ndarray, + dims: "_RailingDims", +) -> tuple[list[np.ndarray], list[np.ndarray]]: + first_arc_end = start_point + cap_dir * dims.terminal_radius + dims.terminal_radius * local_z_down + first_arc_coords = _get_fillet_points( + start_point, start_point + cap_dir * dims.terminal_radius, first_arc_end, dims.terminal_radius + ) + end_point = railing_coords_for_cap[-2].copy() + end_point[NP_Z] -= dims.height_below_handrail + second_arc_coords = _get_fillet_points( + first_arc_end, first_arc_end + local_z_down * dims.terminal_radius, end_point, dims.terminal_radius + ) + cap_coords = [start_point] + first_arc_coords + second_arc_coords + [end_point] + return cap_coords, [first_arc_coords[1], second_arc_coords[1]] + + +# Dispatch table for handrail terminal caps. "NONE" stays out of this table: +# every other cap type appends real geometry to the polyline, so a "NONE" slot +# would need an awkward empty-vstack contract — the dispatcher early-returns +# unchanged instead. +_CAP_BUILDERS: dict[TERMINAL_TYPE, _CapBuilder] = { + "180": _cap_180, + "TO_END_POST": _cap_to_end_post, + "TO_WALL": _cap_to_wall, + "TO_FLOOR": _cap_to_floor, + "TO_END_POST_AND_FLOOR": _cap_to_end_post_and_floor, +} + + +def _add_cap( + railing_coords: np.ndarray, + arc_points_list: list[np.ndarray], + start: bool, + dims: _RailingDims, +) -> tuple[np.ndarray, list[np.ndarray]]: + """Add a handrail terminal cap at one end of the railing. + + Returns the inputs unchanged when ``dims.cap_type == "NONE"``. + """ + if dims.cap_type == "NONE": + return railing_coords, arc_points_list + + railing_coords_for_cap = railing_coords[::-1] if start else railing_coords + arc_points_list = arc_points_list[::-1] if start else arc_points_list + + start_point: np.ndarray = railing_coords_for_cap[-1] + cap_dir = np_normalized(railing_coords_for_cap[-1] - railing_coords_for_cap[-2]) + ortho_dir = np_normalized(np_to_3d(cap_dir[NP_YX] * (1, -1))) + local_z_down = np.cross(cap_dir, ortho_dir) + if start: + ortho_dir = -ortho_dir + + cap_coords, new_arc_points = _CAP_BUILDERS[dims.cap_type]( + railing_coords_for_cap, start_point, cap_dir, ortho_dir, local_z_down, dims + ) + arc_points_list.extend(new_arc_points) + railing_coords = np.vstack((railing_coords_for_cap, cap_coords)) + + if start: + railing_coords = railing_coords[::-1] + arc_points_list = arc_points_list[::-1] + return railing_coords, arc_points_list + + +def _get_arc_indices(points: np.ndarray, arc_pts: list[np.ndarray]) -> list[int]: + points_ = points.copy() + arc_indices = [] + i_base = 0 + for arc_point in arc_pts: + for i, point in enumerate(points_): + if np.allclose(arc_point, point): + current_index = i + i_base + arc_indices.append(current_index) + i_base = current_index + 1 + break + else: + raise Exception( + f"Arc point '{arc_point}' is not present in points:\n{points_}\nFull points data:\n{points}" + ) + points_ = points_[i + 1 :] + return arc_indices + + +def compute_wall_mounted_handrail_geometry( + *, + railing_path: SequenceOfVectors, + support_spacing: float, + railing_diameter: float, + clear_width: float, + height: float, + use_manual_supports: bool = False, + terminal_type: TERMINAL_TYPE = "180", + looped_path: bool = False, + unit_scale: float = 1.0, +) -> WallMountedHandrailGeometry: + """Compute pure geometric data for a wall-mounted handrail. + + The result can be wrapped into an ``IfcShapeRepresentation`` by the + railing-representation API, or converted directly to a Blender bmesh + (or any other viewport mesh) for a live preview that does not mutate + the IFC file. + + Geometric inputs (``railing_path``, ``support_spacing``, + ``railing_diameter``, ``clear_width``, ``height``) are expected in IFC + project units. ``unit_scale`` is used only to convert hard-coded + millimetre constants (fillet radius, support rod radius, etc.) into + project units. + + Constraints: + + - ``railing_path`` must contain at least 2 points. + - ``railing_diameter`` must be > 0. + - ``height`` must be ≥ ``railing_diameter / 2`` (otherwise the + ``TO_FLOOR`` / ``TO_END_POST_AND_FLOOR`` caps extrude upward + instead of down). + - ``clear_width`` must be > 0 (otherwise the support wraps backward + into the wall). + + :param railing_path: Sequence of 3D points along the top of the + handrail (not the centre). + :param support_spacing: Distance between automatic supports. + :param railing_diameter: Handrail tube diameter. + :param clear_width: Clear gap between the wall and the handrail tube. + :param height: Total railing height (top of handrail to floor). + :param use_manual_supports: If true, one support is placed on every + non-collinear vertex of ``railing_path``; if false, supports are + distributed automatically by ``support_spacing``. + :param terminal_type: Style of the terminal end cap, or ``"NONE"`` for + no cap. Ignored when ``looped_path=True`` (no open ends to cap). + :param looped_path: If true, the railing closes on its first point. + :param unit_scale: Output of + :func:`ifcopenshell.util.unit.calculate_unit_scale`. Defaults to + 1.0 (i.e. inputs are already in metres). + """ + railing_radius = railing_diameter / 2 + # for calculations purposes we use height without railing radius + height_below_handrail = height - railing_radius + railing_coords: np.ndarray = np.subtract(railing_path, _Z_DOWN * railing_radius) + + dims = _RailingDims( + railing_radius=railing_radius, + height_below_handrail=height_below_handrail, + terminal_radius=mm(TERMINAL_RADIUS_MM) / unit_scale, + fillet_radius=mm(HANDRAIL_FILLET_RADIUS_MM) / unit_scale, + support_spacing=support_spacing, + support_length=clear_width + railing_radius, + support_arc_radius=mm(SUPPORT_ARC_RADIUS_MM) / unit_scale, + support_disk_radius=railing_radius, + support_disk_depth=mm(SUPPORT_DISK_DEPTH_MM) / unit_scale, + clear_width=clear_width, + cap_type=terminal_type, + ) + + # need to add first two points to the path + # to create the turning arcs and supports on the last segment of the loop + if looped_path: + railing_coords = np.vstack((railing_coords, railing_coords[:2])) + + supports = _collect_supports(railing_coords, use_manual_supports, dims) + railing_coords, arc_points = _add_arcs_on_turning_points(railing_coords, dims, looped_path) + + if not looped_path: + railing_coords, arc_points = _add_cap(railing_coords, arc_points, start=True, dims=dims) + railing_coords, arc_points = _add_cap(railing_coords, arc_points, start=False, dims=dims) + + return WallMountedHandrailGeometry( + handrail_polyline=railing_coords, + handrail_arc_point_indices=_get_arc_indices(railing_coords, arc_points), + handrail_radius=railing_radius, + supports=supports, + ) + + +def _resolve_default_mm(value: Optional[float], default_mm: float, unit_scale: float) -> float: + """Resolve an optional millimetre-defaulted parameter into project units. + + Callers pass ``value`` as the user-supplied override (or ``None``) and + ``default_mm`` as the integer millimetre default; the result is in project + units (``mm/1000 / unit_scale``). + """ + if value is not None: + return value + return mm(default_mm) / unit_scale + def add_railing_representation( file: ifcopenshell.file, *, # keywords only as this API implementation is probably not final # IfcGeometricRepresentationContext context: ifcopenshell.entity_instance, - railing_type: Literal["WALL_MOUNTED_HANDRAIL"] = "WALL_MOUNTED_HANDRAIL", railing_path: SequenceOfVectors, use_manual_supports: bool = False, support_spacing: Optional[float] = None, @@ -72,7 +579,6 @@ def add_railing_representation( Units are expected to be in IFC project units. :param context: IfcGeometricRepresentationContext for the representation. - :param railing_type: Type of the railing. Defaults to "WALL_MOUNTED_HANDRAIL". :param railing_path: A list of points coordinates for the railing path, coordinates are expected to be at the top of the railing, not at the center. If not provided, default path [(0, 0, 1), (1, 0, 1), (2, 0, 1)] (in meters) will be used @@ -81,7 +587,7 @@ def add_railing_representation( :param support_spacing: Distance between supports if automatic supports are used. Defaults to 1m. :param railing_diameter: Railing diameter. Defaults to 50mm. :param clear_width: Clear width between the railing and the wall. Defaults to 40mm. - :param terminal_type: type of the cap. Defaults to "180". + :param terminal_type: type of the cap, or "NONE" for no cap. Defaults to "180". :param height: defaults to 1m :param looped_path: Whether to end the railing on the first point of `railing_path`. Defaults to False. :param unit_scale: The unit scale as calculated by @@ -89,317 +595,51 @@ def add_railing_representation( will be automatically calculated for you. :return: IfcShapeRepresentation for a railing. """ - usecase = Usecase() - usecase.file = file - # define unit_scale first as it's going to be used setting default arguments - settings: dict[str, Any] = { - "unit_scale": ifcopenshell.util.unit.calculate_unit_scale(file) if unit_scale is None else unit_scale, - } - settings.update( - { - "context": context, - "railing_type": railing_path, - "railing_path": ( - railing_path - if railing_path is not None - else usecase.path_si_to_units(V([(0, 0, 1), (1, 0, 1), (2, 0, 1)])) - ), - "use_manual_supports": use_manual_supports, - "support_spacing": support_spacing if support_spacing is not None else usecase.convert_si_to_unit(mm(1000)), - "railing_diameter": ( - railing_diameter if railing_diameter is not None else usecase.convert_si_to_unit(mm(50)) - ), - "clear_width": clear_width if clear_width is not None else usecase.convert_si_to_unit(mm(40)), - "terminal_type": terminal_type, - "height": height if height is not None else usecase.convert_si_to_unit(mm(1000)), - "looped_path": looped_path, - } + if unit_scale is None: + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + + if railing_path is None: + railing_path = V([(0, 0, 1), (1, 0, 1), (2, 0, 1)]) / unit_scale + support_spacing = _resolve_default_mm(support_spacing, DEFAULT_SUPPORT_SPACING_MM, unit_scale) + railing_diameter = _resolve_default_mm(railing_diameter, DEFAULT_RAILING_DIAMETER_MM, unit_scale) + clear_width = _resolve_default_mm(clear_width, DEFAULT_CLEAR_WIDTH_MM, unit_scale) + height = _resolve_default_mm(height, DEFAULT_HEIGHT_MM, unit_scale) + + geometry = compute_wall_mounted_handrail_geometry( + railing_path=railing_path, + use_manual_supports=use_manual_supports, + support_spacing=support_spacing, + railing_diameter=railing_diameter, + clear_width=clear_width, + terminal_type=terminal_type, + height=height, + looped_path=looped_path, + unit_scale=unit_scale, ) - usecase.settings = settings - if railing_type != "WALL_MOUNTED_HANDRAIL": - raise Exception('Only "WALL_MOUNTED_HANDRAIL" railing_type is supported at the moment.') - return usecase.execute() + builder = ShapeBuilder(file) + items_3d: list[ifcopenshell.entity_instance] = [] + for support in geometry.supports: + support_polyline = builder.polyline(support.arc_polyline, closed=False, arc_points=(1,)) + items_3d.append(builder.create_swept_disk_solid(support_polyline, support.arc_radius)) -class Usecase: - file: ifcopenshell.file - settings: dict[str, Any] - - def execute(self): - arc_points: list[np.ndarray] = [] - items_3d: list[ifcopenshell.entity_instance] = [] - builder = ShapeBuilder(self.file) - z_down = V(0, 0, -1) - - # measurements - # from settings - use_manual_supports: bool = self.settings["use_manual_supports"] - railing_radius: float = self.settings["railing_diameter"] / 2 - support_spacing: float = self.settings["support_spacing"] - clear_width: float = self.settings["clear_width"] - # for calculations purposes we use height without railing radius - height: float = self.settings["height"] - railing_radius - cap_type: TERMINAL_TYPE = self.settings["terminal_type"] - ifc_context: ifcopenshell.entity_instance = self.settings["context"] - railing_coords: SequenceOfVectors = self.settings["railing_path"] - looped_path: bool = self.settings["looped_path"] - railing_coords: np.ndarray - railing_coords = np.subtract(railing_coords, z_down * railing_radius) - - # constant - terminal_radius = self.convert_si_to_unit(mm(150)) - railing_fillet_radius = self.convert_si_to_unit(mm(100)) - support_length = clear_width + railing_radius - support_radius = self.convert_si_to_unit(mm(10)) - support_disk_radius = railing_radius - support_disk_depth = self.convert_si_to_unit(mm(20)) - - # util functions - def collinear(d0: np.ndarray, d1: np.ndarray) -> bool: - return is_x(np_angle(d0, d1), 0) - - np_Z = 2 - np_XY = slice(2) - np_YX = [1, 0] - - def add_support_on_point( - point: np.ndarray, railing_direction: np.ndarray - ) -> tuple[ifcopenshell.entity_instance, ...]: - """create a support arc and a disk based on the position and direction of the railing""" - ortho_dir = railing_direction[np_YX] * (1, -1) - ortho_dir = np_normalized(np_to_3d(ortho_dir)) - arc_center = point + ortho_dir * support_length - support_points: list[np.ndarray] = [ - point, - arc_center - ortho_dir * support_length * cos(pi / 4) + z_down * support_length * sin(pi / 4), - arc_center + z_down * support_length, - ] - polyline = builder.polyline(support_points, closed=False, arc_points=(1,)) - solid = builder.create_swept_disk_solid(polyline, support_radius) - - support_disk_circle = builder.circle(radius=support_disk_radius) - - angle = np_angle_signed((0, 1), ortho_dir[np_XY]) - y_extrusion_kwargs = builder.rotate_extrusion_kwargs_by_z(builder.extrude_kwargs("Y"), angle) - support_disk = builder.extrude( - support_disk_circle, support_disk_depth, position=support_points[-1], **y_extrusion_kwargs + disk_circle = builder.circle(radius=support.disk_radius) + y_extrusion_kwargs = builder.rotate_extrusion_kwargs_by_z(builder.extrude_kwargs("Y"), support.disk_z_rotation) + items_3d.append( + builder.extrude( + disk_circle, + support.disk_depth, + position=support.disk_position, + **y_extrusion_kwargs, ) - return (solid, support_disk) - - def get_fillet_points(v0: np.ndarray, v1: np.ndarray, v2: np.ndarray, radius: float) -> list[np.ndarray]: - """get fillet points between edges v0v1 and v1v2""" - dir1 = np_normalized(v0 - v1) - dir2 = np_normalized(v2 - v1) - edge_angle = np_angle(dir1, dir2) - slide_distance = radius / tan(edge_angle / 2) - - fillet_v1co = v1 + (dir1 * slide_distance) - fillet_v2co = v1 + (dir2 * slide_distance) - - normal = np_normal([v0, v1, v2]) - center = np_intersect_line_line( - fillet_v1co, - fillet_v1co + np.cross(normal, dir1), - fillet_v2co, - fillet_v2co + np.cross(normal, dir2), - )[0] - - dir_ = np_normalized(np_lerp(fillet_v1co, fillet_v2co, 0.5) - center) - midpointco = center + dir_ * radius - return [fillet_v1co, midpointco, fillet_v2co] - - def add_arcs_on_turnings_points(base_points: np.ndarray) -> np.ndarray: - """add 3 point fillet arcs on turning points of the railing path""" - if len(base_points) < 3: - return base_points - - # looking for turning points by checking non-collinear edges - output_points: list[np.ndarray] = list(base_points[:1]) - prev_dir = np_normalized(base_points[1] - base_points[0]) - i = 1 - while i < len(base_points) - 1: - cur_dir = np_normalized(base_points[i + 1] - base_points[i]) - - if collinear(cur_dir, prev_dir): - output_points.append(base_points[i]) - else: - fillet_points = get_fillet_points( - base_points[i - 1], base_points[i], base_points[i + 1], railing_fillet_radius - ) - output_points.extend(fillet_points) - arc_points.append(fillet_points[1]) - - prev_dir = cur_dir - i = i + 1 - - if looped_path: - output_points[0] = output_points[-1] - else: - output_points.append(base_points[-1]) - return V(output_points) - - def create_supports_items( - railing_coords: np.ndarray, manual_supports: bool = False - ) -> list[ifcopenshell.entity_instance]: - """create supports items based on the railing coordinates""" - supports_items: list[ifcopenshell.entity_instance] = [] - - # simplified_coords is a list of points that form non-collinear edges - simplified_coords: list[np.ndarray] = [railing_coords[0]] - prev_dir = np_normalized(railing_coords[1] - railing_coords[0]) - - # iterating over each edge of the railing path - for i in range(1, len(railing_coords) - 1): - cur_dir = np_normalized(railing_coords[i + 1] - railing_coords[i]) - - if not collinear(cur_dir, prev_dir): - simplified_coords.append(railing_coords[i]) - prev_dir = cur_dir - - # for manual supports each vertex on the railing path edge - # will be a point for a support - elif manual_supports: - supports_items.extend(add_support_on_point(point=railing_coords[i], railing_direction=cur_dir)) - - simplified_coords.append(railing_coords[-1]) - - if manual_supports: - return supports_items - - # create automatic supports based on the support spacing - for i in range(0, len(simplified_coords) - 1): - v0, v1 = simplified_coords[i : i + 2] - edge = v1 - v0 - length: float = np.linalg.norm(edge) - edge_dir = np_normalized(edge) - n_supports, support_offset = divmod(length, support_spacing) - n_supports = int(n_supports) + 1 - support_offset /= 2 - - start_position = v0 + support_offset * edge_dir - for support_i in range(n_supports): - support_position = start_position + support_i * support_spacing * edge_dir - supports_items.extend(add_support_on_point(point=support_position, railing_direction=edge)) - - return supports_items - - def add_cap(railing_coords: np.ndarray, arc_points: list[np.ndarray], start: bool = False): - """add handrail terminal cap""" - railing_coords_for_cap = railing_coords[::-1] if start else railing_coords - arc_points = arc_points[::-1] if start else arc_points - - start_point: np.ndarray = railing_coords_for_cap[-1] - cap_dir = railing_coords_for_cap[-1] - railing_coords_for_cap[-2] - cap_dir = np_normalized(cap_dir) - ortho_dir = np_to_3d(cap_dir[np_YX] * (1, -1)) - ortho_dir = np_normalized(ortho_dir) - local_z_down = np.cross(cap_dir, ortho_dir) - if start: - ortho_dir = -ortho_dir - - arc_middle_point_cos = sin(radians(45)) - - if cap_type in ("180", "TO_END_POST"): - arc_point = start_point + cap_dir * terminal_radius + terminal_radius * local_z_down - arc_points.append(arc_point) - cap_coords = [arc_point, start_point + terminal_radius * 2 * local_z_down] - - if cap_type == "TO_END_POST": - end_point = railing_coords_for_cap[-2].copy() - end_point[np_Z] -= terminal_radius * 2 - cap_coords.append(end_point) - - elif cap_type == "TO_WALL": - arc_point = ( - start_point - + cap_dir * clear_width * arc_middle_point_cos - + ortho_dir * clear_width * (1 - arc_middle_point_cos) - ) - arc_points.append(arc_point) - cap_coords = [arc_point, start_point + ortho_dir * clear_width + cap_dir * clear_width] - - elif cap_type == "TO_FLOOR": - arc_point = ( - start_point - + cap_dir * terminal_radius * arc_middle_point_cos - + z_down * terminal_radius * (1 - arc_middle_point_cos) - ) - arc_points.append(arc_point) - arc_end = start_point + cap_dir * terminal_radius + terminal_radius * z_down - cap_coords = [ - arc_point, - arc_end, - arc_end + z_down * (height - terminal_radius), - ] - - elif cap_type == "TO_END_POST_AND_FLOOR": - first_arc_end = start_point + cap_dir * terminal_radius + terminal_radius * local_z_down - first_arc_coords = get_fillet_points( - start_point, start_point + cap_dir * terminal_radius, first_arc_end, terminal_radius - ) - arc_points.append(first_arc_coords[1]) - - end_point = railing_coords_for_cap[-2].copy() - end_point[np_Z] -= height - second_arc_coords = get_fillet_points( - first_arc_end, first_arc_end + local_z_down * terminal_radius, end_point, terminal_radius - ) - arc_points.append(second_arc_coords[1]) - cap_coords = [start_point] + first_arc_coords + second_arc_coords + [end_point] - else: - assert_never(cap_type) - - railing_coords = np.vstack((railing_coords_for_cap, cap_coords)) - - if start: - railing_coords = railing_coords[::-1] - arc_points = arc_points[::-1] - return railing_coords, arc_points - - # need to add first two points to the path - # to create the turning arcs and supports on the last segment of the loop - if looped_path: - railing_coords = np.vstack((railing_coords, railing_coords[:2])) - - items_3d.extend(create_supports_items(railing_coords, manual_supports=use_manual_supports)) - railing_coords = add_arcs_on_turnings_points(railing_coords) - - if not looped_path and cap_type != "NONE": - railing_coords, arc_points = add_cap(railing_coords, arc_points, start=True) - railing_coords, arc_points = add_cap(railing_coords, arc_points, start=False) - - def get_arc_indices(points: np.ndarray, arc_points: list[np.ndarray]) -> list[int]: - points_ = points.copy() - arc_indices = [] - i_base = 0 - for arc_point in arc_points: - for i, point in enumerate(points_): - if np.allclose(arc_point, point): - current_index = i + i_base - arc_indices.append(current_index) - i_base = current_index + 1 - break - else: - raise Exception( - f"Arc point '{arc_point}' is not present in points:\n{points_}\nFull points data:\n{points}" - ) - points_ = points_[i + 1 :] - return arc_indices - - railing_path = builder.polyline( - railing_coords, - closed=False, - arc_points=get_arc_indices(railing_coords, arc_points), ) - railing_solid = builder.create_swept_disk_solid(railing_path, railing_radius) - items_3d.append(railing_solid) - representation = builder.get_representation(ifc_context, items=items_3d) - return representation - def convert_si_to_unit(self, value: float) -> float: - return value / self.settings["unit_scale"] + railing_path_entity = builder.polyline( + geometry.handrail_polyline, + closed=False, + arc_points=geometry.handrail_arc_point_indices, + ) + items_3d.append(builder.create_swept_disk_solid(railing_path_entity, geometry.handrail_radius)) - def path_si_to_units(self, path: np.ndarray) -> np.ndarray: - """converts list of vectors from SI to ifc project units""" - return path / self.settings["unit_scale"] + return builder.get_representation(context, items=items_3d) diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_representation.py index e756cb07cf..16dc7a8345 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_representation.py @@ -121,6 +121,12 @@ class Usecase: blender_object: bpy.types.Object def execute(self) -> Union[ifcopenshell.entity_instance, None]: + # IfcTriangulatedFaceSet/IfcPolygonalFaceSet were introduced in IFC4 and + # do not exist in IFC2X3. Without this guard create_mesh_representation() + # silently falls back to a faceted brep, ignoring the requested class. + if self.settings["ifc_representation_class"] == "IfcTessellatedFaceSet" and self.file.schema == "IFC2X3": + raise ValueError("Tessellated face sets (IfcTessellatedFaceSet) are not supported in IFC2X3.") + self.is_manifold = None self.coordinate_offset = self.settings["coordinate_offset"] self.geometry = self.settings["geometry"] diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_window_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_window_representation.py index 36848e7883..7ca50c2348 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_window_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_window_representation.py @@ -27,6 +27,7 @@ import numpy as np import ifcopenshell.api.geometry import ifcopenshell.util.unit from ifcopenshell.util.shape_builder import ShapeBuilder, V +from ifcopenshell.util.unit import mm_to_m as mm # SCHEMAS describe panels setup # where: @@ -59,11 +60,6 @@ DEFAULT_PANEL_SCHEMAS = { } -def mm(x: float) -> float: - """mm to meters shortcut for readability""" - return x / 1000 - - def create_ifc_window_frame_simple( builder: ShapeBuilder, size: np.ndarray, thickness: Union[list[float], float], position: Optional[np.ndarray] = None ) -> list[ifcopenshell.entity_instance]: diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/validate_type.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/validate_type.py index 3731b2fffc..39ea232e25 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/validate_type.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/validate_type.py @@ -81,6 +81,13 @@ def validate_type( if not preferred_item and remaining_items: preferred_item = remaining_items[0] + # preferred_item must not appear in remaining_items — if it was selected from + # that list, leaving it in causes add_boolean to union it with itself, and the + # subsequent Items filter then removes ALL items (including preferred_item), + # leaving Items=[] which guess_type maps to "MappedRepresentation". + if preferred_item in remaining_items: + remaining_items = [i for i in remaining_items if i != preferred_item] + if remaining_items: ifcopenshell.api.geometry.add_boolean(file, preferred_item, remaining_items, "UNION") representation.Items = [i for i in representation.Items if i not in remaining_items] diff --git a/src/ifcopenshell-python/ifcopenshell/draw.py b/src/ifcopenshell-python/ifcopenshell/draw.py index 6d3f2f8286..7ea766ebe6 100644 --- a/src/ifcopenshell-python/ifcopenshell/draw.py +++ b/src/ifcopenshell-python/ifcopenshell/draw.py @@ -42,7 +42,8 @@ WHITE = numpy.array((1.0, 1.0, 1.0)) DO_NOTHING = lambda *args: None -ARRANGE_POLYGON_SETTINGS = W.arrange_polygon_settings() if hasattr(W, 'arrange_polygon_settings') else None +ARRANGE_POLYGON_SETTINGS = W.arrange_polygon_settings() if hasattr(W, "arrange_polygon_settings") else None + @dataclass class draw_settings: @@ -527,7 +528,10 @@ def main( *(tup for i, tup in enumerate(zip(path_objects, section_polies, polies)) if has_relevant_zone(i)) ) - arranged = W.arrange_polygons(*filter(None, (ARRANGE_POLYGON_SETTINGS,)), polies) + arranged = W.arrange_polygons( + *filter(None, (ARRANGE_POLYGON_SETTINGS,)), + polies, # ty: ignore[too-many-positional-arguments] + ) svg_data_3 = W.polygons_to_svg(arranged, False) dom3 = parseString(svg_data_3) svg3 = dom3.childNodes[0] diff --git a/src/ifcopenshell-python/ifcopenshell/express/schema_class.py b/src/ifcopenshell-python/ifcopenshell/express/schema_class.py index 4dd5e9d450..a1376a07c3 100644 --- a/src/ifcopenshell-python/ifcopenshell/express/schema_class.py +++ b/src/ifcopenshell-python/ifcopenshell/express/schema_class.py @@ -401,7 +401,15 @@ class SchemaClass(codegen.Base): if isinstance(type, nodes.AggregationType): aggr_type = type.aggregate_type - make_bound = lambda b: -1 if b == "?" else int(b) + + def make_bound(b): + # `?` and non-literal bounds (attribute references, arithmetic expressions) collapse to -1. + # + try: + return int(b) + except (TypeError, ValueError): + return -1 + bound1, bound2 = map(make_bound, (type.bounds.lower, type.bounds.upper)) decl_type = get_declared_type(type.type, emitted_names) return x.aggregation_type(aggr_type, bound1, bound2, decl_type) @@ -528,7 +536,16 @@ class SchemaClass(codegen.Base): inv_attrs = [] for attr in type.inverse: if attr.bounds: - make_bound = lambda b: -1 if b == "?" else int(b) + + def make_bound(b): + # `?` and non-literal bounds (attribute references, arithmetic + # expressions) collapse to -1 (unbounded) — the C++ runtime has + # no third state for "dynamic cardinality". + try: + return int(b) + except (TypeError, ValueError): + return -1 + bound1, bound2 = map(make_bound, (attr.bounds.lower, attr.bounds.upper)) else: bound1, bound2 = -1, -1 diff --git a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi index 777c00d106..84f0b5aa2d 100644 --- a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi +++ b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi @@ -1687,7 +1687,7 @@ class type_declaration(declaration): class uninitialized_tag: ... -def arrange_polygons(polygons): ... +def arrange_polygons(settings, polygons): ... def clear_plugin_search_paths() -> None: ... def clear_schemas(): ... def construct_iterator(geometry_library, settings, file, num_threads): ... diff --git a/src/ifcopenshell-python/ifcopenshell/simple_spf b/src/ifcopenshell-python/ifcopenshell/simple_spf index 2849a31788..9400d243d8 160000 --- a/src/ifcopenshell-python/ifcopenshell/simple_spf +++ b/src/ifcopenshell-python/ifcopenshell/simple_spf @@ -1 +1 @@ -Subproject commit 2849a31788c4f82edca7d1b1046d0606fdf8b9be +Subproject commit 9400d243d880dace57490949d74ab1932ce99a09 diff --git a/src/ifcopenshell-python/ifcopenshell/util/cost.py b/src/ifcopenshell-python/ifcopenshell/util/cost.py index 875594f1a5..4354e49e90 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/cost.py +++ b/src/ifcopenshell-python/ifcopenshell/util/cost.py @@ -196,9 +196,12 @@ def get_cost_items_for_product(product: ifcopenshell.entity_instance) -> list[if :return: A list of IfcCostItem objects representing the cost items related to the product. """ cost_items = [] - for assignment in product.HasAssignments: - if assignment.is_a("IfcRelAssignsToControl") and assignment.RelatingControl.is_a("IfcCostItem"): - cost_items.append(assignment.RelatingControl) + for assignment in product.HasAssignments or []: + if assignment.is_a("IfcRelAssignsToControl"): + control = assignment.RelatingControl + if control and control.is_a("IfcCostItem"): + cost_items.append(control) + return cost_items diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index db2d1ce6c5..e61647c6eb 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -914,7 +914,7 @@ class FacetTransformer(lark.Transformer): if self.elements: self.results.append(self.elements) self.elements = set() - self.has_additive_facet_in_current_list = False + self.has_additive_facet_in_current_list = False def instance(self, args): self.has_additive_facet_in_current_list = True diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index e53d069a76..d9d18f0b5f 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -35,6 +35,15 @@ import ifcopenshell.util.unit PRECISION = 1.0e-5 +# Numpy axis-index helpers for 3D coordinates. Use these instead of redefining +# local copies in every geometry-builder module — they index ``np.ndarray`` +# vectors of shape ``(3,)`` or ``(N, 3)``. +NP_X, NP_Y, NP_Z = 0, 1, 2 +NP_XY = slice(2) +NP_XZ = [0, 2] +NP_YZ = [1, 2] +NP_YX = [1, 0] + if TYPE_CHECKING: # NOTE: mathutils is never used at runtime in ifcopenshell, @@ -1826,7 +1835,7 @@ class ShapeBuilder: end_half_dim: np.ndarray, angle: float, profile_offset: VectorType = (0.0, 0.0), - verbose: bool = True, + verbose: bool = False, ) -> Optional[float]: """Get the transition length for two profile half-dimensions, an angle, and an XY offset. @@ -1838,7 +1847,9 @@ class ShapeBuilder: :param end_half_dim: Half-dimensions of the end profile in the same format. :param angle: Maximum allowed transition angle, in degrees. :param profile_offset: 2D XY offset between the centrelines of the start and end profiles. - :param verbose: If True, print diagnostic values during calculation. + :param verbose: If True, print diagnostic values during calculation. Default is False — + the prints are debug-only output; enabling them spams the console on every transition + geometry computation (which fires per-fitting on IFC load). :return: Transition length in project length units, or ``None`` if no valid length exists for the given angle and offset. """ @@ -1899,7 +1910,7 @@ class ShapeBuilder: end_profile: bool = False, length: Optional[float] = None, angle: Optional[float] = None, - verbose: bool = True, + verbose: bool = False, ) -> Union[float, None]: """Calculate MEP transition length from angle, or transition angle from length. diff --git a/src/ifcopenshell-python/ifcopenshell/util/unit.py b/src/ifcopenshell-python/ifcopenshell/util/unit.py index add272dec8..cc55442715 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/unit.py +++ b/src/ifcopenshell-python/ifcopenshell/util/unit.py @@ -644,6 +644,11 @@ def convert_unit(value: float, from_unit: ifcopenshell.entity_instance, to_unit: ) +def mm_to_m(value: float) -> float: + """Convert a millimetre value to metres.""" + return value / 1000 + + def convert(value: float, from_prefix: Optional[str], from_unit: str, to_prefix: Optional[str], to_unit: str) -> float: """Converts between length, area, and volume units diff --git a/src/ifcopenshell-python/pyproject.toml b/src/ifcopenshell-python/pyproject.toml index 9bcaeeebaa..288e3e3585 100644 --- a/src/ifcopenshell-python/pyproject.toml +++ b/src/ifcopenshell-python/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "isodate", "python-dateutil", "lark", + "pyparsing", "typing-extensions", ] diff --git a/src/ifcopenshell-python/test/api/alignment/test_add_segment_to_layout.py b/src/ifcopenshell-python/test/api/alignment/test_add_segment_to_layout.py index 51350b1ac2..bb607e8605 100644 --- a/src/ifcopenshell-python/test/api/alignment/test_add_segment_to_layout.py +++ b/src/ifcopenshell-python/test/api/alignment/test_add_segment_to_layout.py @@ -48,6 +48,12 @@ def test_add_segment_to_layout(): ) alignment = ifcopenshell.api.alignment.create(file, "") + + referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment) + assert ( + len(referent_nest.RelatedObjects) == 1 + ) # the alignment creates the stationing nest and it has one referent to defined the stationing for the alignment + horizontal_alignment = ifcopenshell.api.alignment.get_horizontal_layout(alignment) design_parameters = file.create_entity( @@ -80,4 +86,4 @@ def test_add_segment_to_layout(): segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal_alignment) assert len(segment_nest.RelatedObjects) == 2 referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment) - assert len(referent_nest.RelatedObjects) == 3 + assert len(referent_nest.RelatedObjects) == 1 # test this a second time to make sure that it is still true diff --git a/src/ifcopenshell-python/test/api/alignment/test_add_vertical_alignment.py b/src/ifcopenshell-python/test/api/alignment/test_add_vertical_alignment.py index 74926b885a..e4b544b361 100644 --- a/src/ifcopenshell-python/test/api/alignment/test_add_vertical_alignment.py +++ b/src/ifcopenshell-python/test/api/alignment/test_add_vertical_alignment.py @@ -47,7 +47,9 @@ def test_add_vertical_alignment(): assert len(layout_nest.RelatedObjects) == 1 assert layout_nest.RelatedObjects[0].is_a("IfcAlignmentHorizontal") referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment) - assert len(referent_nest.RelatedObjects) == 2 + assert ( + len(referent_nest.RelatedObjects) == 1 + ) # the alignment creates the stationing nest and it has one referent to defined the stationing for the alignment assert referent_nest.RelatedObjects[0].is_a("IfcReferent") curve = ifcopenshell.api.alignment.get_curve(alignment) @@ -72,7 +74,7 @@ def test_add_vertical_alignment(): for child_alignment in alignment.IsDecomposedBy[0].RelatedObjects: assert child_alignment.is_a("IfcAlignment") - assert len(child_alignment.IsNestedBy) == 2 + assert len(child_alignment.IsNestedBy) == 1 child_layout_nest = ifcopenshell.api.alignment.get_alignment_layout_nest(child_alignment) assert len(child_layout_nest.RelatedObjects) == 1 # The IfcAlignmentVertical assert child_layout_nest.RelatedObjects[0].is_a("IfcAlignmentVertical") diff --git a/src/ifcopenshell-python/test/api/alignment/test_create_by_pi_method.py b/src/ifcopenshell-python/test/api/alignment/test_create_by_pi_method.py index 28de8e9dab..d2783a97eb 100644 --- a/src/ifcopenshell-python/test/api/alignment/test_create_by_pi_method.py +++ b/src/ifcopenshell-python/test/api/alignment/test_create_by_pi_method.py @@ -62,7 +62,7 @@ def test_create_by_pi_method(): assert len(layout_nest.RelatedObjects) == 2 referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment) - assert len(referent_nest.RelatedObjects) == 19 + assert len(referent_nest.RelatedObjects) == 1 horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment) horizontal_segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal_layout) diff --git a/src/ifcopenshell-python/test/api/alignment/test_create_layout_segment.py b/src/ifcopenshell-python/test/api/alignment/test_create_layout_segment.py index 1897e3ce71..0bd2ac12cc 100644 --- a/src/ifcopenshell-python/test/api/alignment/test_create_layout_segment.py +++ b/src/ifcopenshell-python/test/api/alignment/test_create_layout_segment.py @@ -82,9 +82,16 @@ def _test_horizontal() -> ifcopenshell.file: assert y == 0.0 assert z == 0.0 + # check the start point of the zero length segment + assert horizontal_alignment.IsNestedBy[0].RelatedObjects[1].DesignParameters.SegmentLength == 0.0 + assert horizontal_alignment.IsNestedBy[0].RelatedObjects[1].DesignParameters.StartPoint.Coordinates[0] == x + assert horizontal_alignment.IsNestedBy[0].RelatedObjects[1].DesignParameters.StartPoint.Coordinates[1] == y + curve = ifcopenshell.api.alignment.get_curve(ali) assert curve.is_a("IfcCompositeCurve") assert len(curve.Segments) == 2 + assert curve.Segments[0].Transition == "CONTSAMEGRADIENTSAMECURVATURE" + assert curve.Segments[1].Transition == "DISCONTINUOUS" design_parameters = file.create_entity( type="IfcAlignmentHorizontalSegment", @@ -110,9 +117,16 @@ def _test_horizontal() -> ifcopenshell.file: assert y == 50.0 * math.sin(math.pi / 6) assert z == 0.0 + # check the start point of the zero length segment + assert horizontal_alignment.IsNestedBy[0].RelatedObjects[2].DesignParameters.SegmentLength == 0.0 + assert horizontal_alignment.IsNestedBy[0].RelatedObjects[2].DesignParameters.StartPoint.Coordinates[0] == x + assert horizontal_alignment.IsNestedBy[0].RelatedObjects[2].DesignParameters.StartPoint.Coordinates[1] == y + curve = ifcopenshell.api.alignment.get_curve(ali) assert curve.is_a("IfcCompositeCurve") assert len(curve.Segments) == 3 + assert curve.Segments[1].Transition == "CONTSAMEGRADIENTSAMECURVATURE" + assert curve.Segments[2].Transition == "DISCONTINUOUS" return file diff --git a/src/ifcopenshell-python/test/api/alignment/test_create_no_geometry.py b/src/ifcopenshell-python/test/api/alignment/test_create_no_geometry.py index a491ef8661..21d7023d22 100644 --- a/src/ifcopenshell-python/test/api/alignment/test_create_no_geometry.py +++ b/src/ifcopenshell-python/test/api/alignment/test_create_no_geometry.py @@ -60,7 +60,14 @@ def test_create_no_geometry(): PredefinedType="LINE", ) end = ifcopenshell.api.alignment.create_layout_segment(file, horizontal_alignment, design_parameters) - assert end == None + + x = end[0, 3] + y = end[1, 3] + z = end[2, 3] + + assert x == 100.0 + assert y == 0.0 + assert z == 0.0 design_parameters = file.createIfcAlignmentVerticalSegment( StartDistAlong=0.0, @@ -71,4 +78,11 @@ def test_create_no_geometry(): PredefinedType="CONSTANTGRADIENT", ) end = ifcopenshell.api.alignment.create_layout_segment(file, vertical_alignment, design_parameters) - assert end == None + + x = end[0, 3] + y = end[1, 3] + z = end[2, 3] + + assert x == 50.0 + assert y == 20.0 + 50.0 * 1.0 / 100.0 + assert z == 0.0 diff --git a/src/ifcopenshell-python/test/api/alignment/test_create_representation.py b/src/ifcopenshell-python/test/api/alignment/test_create_representation.py new file mode 100644 index 0000000000..3d9bb3be7f --- /dev/null +++ b/src/ifcopenshell-python/test/api/alignment/test_create_representation.py @@ -0,0 +1,443 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 Thomas Krijnen +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 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 +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + + +import math + +import pytest +import ifcopenshell +import ifcopenshell.api.alignment +import ifcopenshell.api.unit +import numpy as np + + +def test_create_representation(): + # expected values for horizontal segment ends points (X,Y,dx,dy) + h_expected = [ + (500.0, 2500.0, math.cos(math.radians(327.0613)), math.sin(math.radians(327.0613))), + (2142.2378194934668, 1436.0145490066361, 0.8392527899703555, -0.5437414408769801), + (3660.446048592728, 2050.735651565721, 0.22453168741127044, 0.9744667882222808), + (4084.1161141648777, 3889.4623490042068, 0.22453168741127047, 0.9744667882222809), + (5469.395455576321, 4847.565492667097, 0.9910142023415828, -0.13375668490687387), + (7019.971720182908, 4638.284999653966, 0.9910142023415827, -0.13375668490687387), + (7790.932377201981, 4006.729563689594, 0.32621900658961334, -0.9452942186111613), + (8479.999918938518, 2009.9986857258034, 0.32621900658961345, -0.9452942186111613), + ] + + # expected values for vertical segment ends points (X,Y,dx,dy) + v_expected = [ + (0.0, 100.0, 0.999846910161925, 0.01749732092783369), + (1200.0, 121.0, 0.999846910161925, 0.01749732092783369), + (2799.99999384661, 127.00000006153391, 0.9999500037507449, -0.009999499931751348), + (4399.99999384661, 111.00000023075212, 0.999950003750745, -0.009999499931751352), + (5599.9999883553455, 117.00000018438367, 0.999800059982751, 0.019996001062400855), + (6399.999988355345, 133.0000000745584, 0.999800059982751, 0.019996001062400855), + (8399.99998428796, 133.00000001862446, 0.999800059981633, -0.019996001118301257), + (9399.99998428796, 113.00000009997211, 0.999800059981633, -0.019996001118301257), + (10199.99998062693, 103.00000015081635, 0.9999875002340269, -0.004999937569813611), + (12799.99998062693, 89.99999997234107, 0.9999875002340269, -0.004999937569813611), + ] + + file = ifcopenshell.file(schema="IFC4X3_ADD2") + file.header.file_description.description = ["ViewDefinition [Alignment-basedView]"] + + project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="FHWA Alignment") + # ifcopenshell.api.unit.assign_unit(file) + # length = ifcopenshell.api.unit.add_si_unit(file,unit_type="LENGTHUNIT") + length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot") + ifcopenshell.api.unit.assign_unit(file, units=[length]) + geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model") + axis_model_representation_subcontext = ifcopenshell.api.context.add_context( + file, + context_type="Model", + context_identifier="Axis", + target_view="MODEL_VIEW", + parent=geometric_representation_context, + ) + + site = file.createIfcSite(GlobalId=ifcopenshell.guid.new(), Name="Site") + ifcopenshell.api.aggregate.assign_object(file, relating_object=project, products=[site]) + + alignment = ifcopenshell.api.alignment.create( + file, "E-Line", include_vertical=True, start_station=10000.0, include_geometry=False + ) + + # alignment is referenced into spatial structure of site per CT 4.1.5.1 + ifcopenshell.api.spatial.reference_structure(file, products=[alignment], relating_structure=site) + + layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + + segment1 = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint(Coordinates=((500.0, 2500.0))), + StartDirection=math.radians(327.0613), + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=0.0, + SegmentLength=1956.785654, + PredefinedType="LINE", + ) + + end = ifcopenshell.api.alignment.create_layout_segment(file, layout, segment1) + + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + + x = float(end[0, 3]) / unit_scale + y = float(end[1, 3]) / unit_scale + dx = float(end[0, 0]) + dy = float(end[1, 0]) + dir = math.atan2(dy, dx) + assert ( + pytest.approx(h_expected[1][0]) == x + and pytest.approx(h_expected[1][1]) == y + and pytest.approx(h_expected[1][2]) == dx + and pytest.approx(h_expected[1][3]) == dy + ) + segment2 = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((x, y)), + StartDirection=dir, + StartRadiusOfCurvature=1000.0, + EndRadiusOfCurvature=1000.0, + SegmentLength=1919.222667, + PredefinedType="CIRCULARARC", + ) + end = ifcopenshell.api.alignment.create_layout_segment(file, layout, segment2) + + x = float(end[0, 3]) / unit_scale + y = float(end[1, 3]) / unit_scale + dx = float(end[0, 0]) + dy = float(end[1, 0]) + dir = math.atan2(dy, dx) + assert ( + pytest.approx(h_expected[2][0]) == x + and pytest.approx(h_expected[2][1]) == y + and pytest.approx(h_expected[2][2]) == dx + and pytest.approx(h_expected[2][3]) == dy + ) + segment3 = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((x, y)), + StartDirection=dir, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=0.0, + SegmentLength=1886.905454, + PredefinedType="LINE", + ) + end = ifcopenshell.api.alignment.create_layout_segment(file, layout, segment3) + + x = float(end[0, 3]) / unit_scale + y = float(end[1, 3]) / unit_scale + dx = float(end[0, 0]) + dy = float(end[1, 0]) + dir = math.atan2(dy, dx) + assert ( + pytest.approx(h_expected[3][0]) == x + and pytest.approx(h_expected[3][1]) == y + and pytest.approx(h_expected[3][2]) == dx + and pytest.approx(h_expected[3][3]) == dy + ) + segment4 = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((x, y)), + StartDirection=dir, + StartRadiusOfCurvature=-1250.0, + EndRadiusOfCurvature=-1250.0, + SegmentLength=1848.115835, + PredefinedType="CIRCULARARC", + ) + end = ifcopenshell.api.alignment.create_layout_segment(file, layout, segment4) + + x = float(end[0, 3]) / unit_scale + y = float(end[1, 3]) / unit_scale + dx = float(end[0, 0]) + dy = float(end[1, 0]) + dir = math.atan2(dy, dx) + assert ( + pytest.approx(h_expected[4][0]) == x + and pytest.approx(h_expected[4][1]) == y + and pytest.approx(h_expected[4][2]) == dx + and pytest.approx(h_expected[4][3]) == dy + ) + segment5 = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((x, y)), + StartDirection=dir, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=0.0, + SegmentLength=1564.635765, + PredefinedType="LINE", + ) + end = ifcopenshell.api.alignment.create_layout_segment(file, layout, segment5) + + x = float(end[0, 3]) / unit_scale + y = float(end[1, 3]) / unit_scale + dx = float(end[0, 0]) + dy = float(end[1, 0]) + dir = math.atan2(dy, dx) + assert ( + pytest.approx(h_expected[5][0]) == x + and pytest.approx(h_expected[5][1]) == y + and pytest.approx(h_expected[5][2]) == dx + and pytest.approx(h_expected[5][3]) == dy + ) + segment6 = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((x, y)), + StartDirection=dir, + StartRadiusOfCurvature=-950.0, + EndRadiusOfCurvature=-950.0, + SegmentLength=1049.119737, + PredefinedType="CIRCULARARC", + ) + end = ifcopenshell.api.alignment.create_layout_segment(file, layout, segment6) + + x = float(end[0, 3]) / unit_scale + y = float(end[1, 3]) / unit_scale + dx = float(end[0, 0]) + dy = float(end[1, 0]) + dir = math.atan2(dy, dx) + assert ( + pytest.approx(h_expected[6][0]) == x + and pytest.approx(h_expected[6][1]) == y + and pytest.approx(h_expected[6][2]) == dx + and pytest.approx(h_expected[6][3]) == dy + ) + segment7 = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((x, y)), + StartDirection=dir, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=0.0, + SegmentLength=2112.285084, + PredefinedType="LINE", + ) + end = ifcopenshell.api.alignment.create_layout_segment(file, layout, segment7) + x = float(end[0, 3]) / unit_scale + y = float(end[1, 3]) / unit_scale + dx = float(end[0, 0]) + dy = float(end[1, 0]) + assert ( + pytest.approx(h_expected[7][0]) == x + and pytest.approx(h_expected[7][1]) == y + and pytest.approx(h_expected[7][2]) == dx + and pytest.approx(h_expected[7][3]) == dy + ) + + vlayout = ifcopenshell.api.alignment.get_vertical_layout(alignment) + + segment1 = file.createIfcAlignmentVerticalSegment( + StartDistAlong=0.0, + HorizontalLength=1200.0, + StartHeight=100.0, + StartGradient=1.75 / 100.0, + EndGradient=1.75 / 100.0, + PredefinedType="CONSTANTGRADIENT", + ) + + end = ifcopenshell.api.alignment.create_layout_segment(file, vlayout, segment1) + + x = float(end[0, 3]) / unit_scale + y = float(end[1, 3]) / unit_scale + dx = float(end[0, 0]) + dy = float(end[1, 0]) + assert ( + pytest.approx(v_expected[1][0]) == x + and pytest.approx(v_expected[1][1]) == y + and pytest.approx(v_expected[1][2]) == dx + and pytest.approx(v_expected[1][3]) == dy + ) + segment2 = file.createIfcAlignmentVerticalSegment( + StartDistAlong=x, + HorizontalLength=1600.0, + StartHeight=y, + StartGradient=dy / dx, + EndGradient=-1.0 / 100.0, + PredefinedType="PARABOLICARC", + ) + end = ifcopenshell.api.alignment.create_layout_segment(file, vlayout, segment2) + + x = float(end[0, 3]) / unit_scale + y = float(end[1, 3]) / unit_scale + dx = float(end[0, 0]) + dy = float(end[1, 0]) + assert ( + pytest.approx(v_expected[2][0]) == x + and pytest.approx(v_expected[2][1]) == y + and pytest.approx(v_expected[2][2]) == dx + and pytest.approx(v_expected[2][3]) == dy + ) + segment3 = file.createIfcAlignmentVerticalSegment( + StartDistAlong=x, + HorizontalLength=1600.0, + StartHeight=y, + StartGradient=dy / dx, + EndGradient=-1.0 / 100.0, + PredefinedType="CONSTANTGRADIENT", + ) + end = ifcopenshell.api.alignment.create_layout_segment(file, vlayout, segment3) + + x = float(end[0, 3]) / unit_scale + y = float(end[1, 3]) / unit_scale + dx = float(end[0, 0]) + dy = float(end[1, 0]) + assert ( + pytest.approx(v_expected[3][0]) == x + and pytest.approx(v_expected[3][1]) == y + and pytest.approx(v_expected[3][2]) == dx + and pytest.approx(v_expected[3][3]) == dy + ) + segment4 = file.createIfcAlignmentVerticalSegment( + StartDistAlong=x, + HorizontalLength=1200.0, + StartHeight=y, + StartGradient=dy / dx, + EndGradient=2.0 / 100.0, + PredefinedType="PARABOLICARC", + ) + end = ifcopenshell.api.alignment.create_layout_segment(file, vlayout, segment4) + + x = float(end[0, 3]) / unit_scale + y = float(end[1, 3]) / unit_scale + dx = float(end[0, 0]) + dy = float(end[1, 0]) + assert ( + pytest.approx(v_expected[4][0]) == x + and pytest.approx(v_expected[4][1]) == y + and pytest.approx(v_expected[4][2]) == dx + and pytest.approx(v_expected[4][3]) == dy + ) + segment5 = file.createIfcAlignmentVerticalSegment( + StartDistAlong=x, + HorizontalLength=800.0, + StartHeight=y, + StartGradient=dy / dx, + EndGradient=2.0 / 100.0, + PredefinedType="CONSTANTGRADIENT", + ) + end = ifcopenshell.api.alignment.create_layout_segment(file, vlayout, segment5) + + x = float(end[0, 3]) / unit_scale + y = float(end[1, 3]) / unit_scale + dx = float(end[0, 0]) + dy = float(end[1, 0]) + assert ( + pytest.approx(v_expected[5][0]) == x + and pytest.approx(v_expected[5][1]) == y + and pytest.approx(v_expected[5][2]) == dx + and pytest.approx(v_expected[5][3]) == dy + ) + segment6 = file.createIfcAlignmentVerticalSegment( + StartDistAlong=x, + HorizontalLength=2000.0, + StartHeight=y, + StartGradient=dy / dx, + EndGradient=-2.0 / 100.0, + PredefinedType="PARABOLICARC", + ) + end = ifcopenshell.api.alignment.create_layout_segment(file, vlayout, segment6) + + x = float(end[0, 3]) / unit_scale + y = float(end[1, 3]) / unit_scale + dx = float(end[0, 0]) + dy = float(end[1, 0]) + assert ( + pytest.approx(v_expected[6][0]) == x + and pytest.approx(v_expected[6][1]) == y + and pytest.approx(v_expected[6][2]) == dx + and pytest.approx(v_expected[6][3]) == dy + ) + segment7 = file.createIfcAlignmentVerticalSegment( + StartDistAlong=x, + HorizontalLength=1000.0, + StartHeight=y, + StartGradient=dy / dx, + EndGradient=-2.0 / 100.0, + PredefinedType="CONSTANTGRADIENT", + ) + end = ifcopenshell.api.alignment.create_layout_segment(file, vlayout, segment7) + + x = float(end[0, 3]) / unit_scale + y = float(end[1, 3]) / unit_scale + dx = float(end[0, 0]) + dy = float(end[1, 0]) + assert ( + pytest.approx(v_expected[7][0]) == x + and pytest.approx(v_expected[7][1]) == y + and pytest.approx(v_expected[7][2]) == dx + and pytest.approx(v_expected[7][3]) == dy + ) + segment8 = file.createIfcAlignmentVerticalSegment( + StartDistAlong=x, + HorizontalLength=800.0, + StartHeight=y, + StartGradient=dy / dx, + EndGradient=-0.5 / 100.0, + PredefinedType="PARABOLICARC", + ) + end = ifcopenshell.api.alignment.create_layout_segment(file, vlayout, segment8) + + x = float(end[0, 3]) / unit_scale + y = float(end[1, 3]) / unit_scale + dx = float(end[0, 0]) + dy = float(end[1, 0]) + assert ( + pytest.approx(v_expected[8][0]) == x + and pytest.approx(v_expected[8][1]) == y + and pytest.approx(v_expected[8][2]) == dx + and pytest.approx(v_expected[8][3]) == dy + ) + segment9 = file.createIfcAlignmentVerticalSegment( + StartDistAlong=x, + HorizontalLength=2600.0, + StartHeight=y, + StartGradient=dy / dx, + EndGradient=-0.5 / 100.0, + PredefinedType="CONSTANTGRADIENT", + ) + end = ifcopenshell.api.alignment.create_layout_segment(file, vlayout, segment9) + x = float(end[0, 3]) / unit_scale + y = float(end[1, 3]) / unit_scale + dx = float(end[0, 0]) + dy = float(end[1, 0]) + assert ( + pytest.approx(v_expected[9][0]) == x + and pytest.approx(v_expected[9][1]) == y + and pytest.approx(v_expected[9][2]) == dx + and pytest.approx(v_expected[9][3]) == dy + ) + + ifcopenshell.api.alignment.create_representation(file, alignment) + + curve = ifcopenshell.api.alignment.get_basis_curve(alignment) + assert curve.is_a("IfcCompositeCurve") + for s in curve.Segments: + assert len(s.UsingCurves) == 1 + + curve = ifcopenshell.api.alignment.get_layout_curve(layout) + assert curve.is_a("IfcCompositeCurve") + for index, s in enumerate(curve.Segments): + assert len(s.UsingCurves) == 1 + assert s.Placement.Location.Coordinates[0] == pytest.approx(h_expected[index][0]) + assert s.Placement.Location.Coordinates[1] == pytest.approx(h_expected[index][1]) + assert s.Placement.RefDirection.DirectionRatios[0] == pytest.approx(h_expected[index][2]) + assert s.Placement.RefDirection.DirectionRatios[1] == pytest.approx(h_expected[index][3]) + + curve = ifcopenshell.api.alignment.get_layout_curve(vlayout) + assert curve.is_a("IfcGradientCurve") + for index, s in enumerate(curve.Segments): + assert len(s.UsingCurves) == 1 + assert s.Placement.Location.Coordinates[0] == pytest.approx(v_expected[index][0]) + assert s.Placement.Location.Coordinates[1] == pytest.approx(v_expected[index][1]) + assert s.Placement.RefDirection.DirectionRatios[0] == pytest.approx(v_expected[index][2]) + assert s.Placement.RefDirection.DirectionRatios[1] == pytest.approx(v_expected[index][3]) + + +test_create_representation() diff --git a/src/ifcopenshell-python/test/api/alignment/test_vertical_layout_by_pi_method.py b/src/ifcopenshell-python/test/api/alignment/test_vertical_layout_by_pi_method.py index 2494bf0e3f..1f113729f0 100644 --- a/src/ifcopenshell-python/test/api/alignment/test_vertical_layout_by_pi_method.py +++ b/src/ifcopenshell-python/test/api/alignment/test_vertical_layout_by_pi_method.py @@ -75,7 +75,7 @@ def test_vertical_layout_by_pi_method(): assert len(layout_nest.RelatedObjects) == 2 referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment) - assert len(referent_nest.RelatedObjects) == 6 + assert len(referent_nest.RelatedObjects) == 1 segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(vlayout) assert len(segment_nest.RelatedObjects) == 3 diff --git a/src/ifcopenshell-python/test/api/geometry/test_add_railing_representation.py b/src/ifcopenshell-python/test/api/geometry/test_add_railing_representation.py new file mode 100644 index 0000000000..a1e1bf0812 --- /dev/null +++ b/src/ifcopenshell-python/test/api/geometry/test_add_railing_representation.py @@ -0,0 +1,332 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 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 +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Tests for ``ifcopenshell.api.geometry.add_railing_representation``. + +The module under test was refactored to separate **pure-geometry compute** +(``compute_wall_mounted_handrail_geometry``) from **IFC entity creation** +(``add_railing_representation`` itself). The split lets Bonsai drive a +viewport-only preview without mutating the IFC file (issue #7439). + +The bulk of the tests here exercise the pure compute function — it accepts +plain Python/NumPy inputs, returns a dataclass, and has no IFC dependency. +A smaller smoke test then runs the full ``add_railing_representation`` end +to end on a real ifcopenshell.file to confirm the IFC wrapping still +produces a valid ``IfcShapeRepresentation`` containing the expected items. +""" + +import numpy as np +import pytest + +import ifcopenshell.api.context +import ifcopenshell.api.geometry +import ifcopenshell.api.root +import ifcopenshell.api.unit +import test.bootstrap +from ifcopenshell.api.geometry import ( + RailingSupport, + WallMountedHandrailGeometry, + compute_wall_mounted_handrail_geometry, +) + +# --------------------------------------------------------------------------- +# Pure-geometry compute tests (no IFC file needed) +# --------------------------------------------------------------------------- + + +def _straight_path(length: float = 2.0) -> list[tuple[float, float, float]]: + """Two-point horizontal path along +X at handrail height (1m).""" + return [(0.0, 0.0, 1.0), (length, 0.0, 1.0)] + + +def _l_path() -> list[tuple[float, float, float]]: + """L-shaped path that turns 90° — exercises the fillet-arc branch.""" + return [(0.0, 0.0, 1.0), (2.0, 0.0, 1.0), (2.0, 2.0, 1.0)] + + +def _common_kwargs(**overrides): + """Default kwargs roughly matching ``add_railing_representation``'s defaults at unit_scale=1.""" + kwargs = dict( + support_spacing=1.0, + railing_diameter=0.050, + clear_width=0.040, + height=1.0, + use_manual_supports=False, + terminal_type="180", + looped_path=False, + unit_scale=1.0, + ) + kwargs.update(overrides) + return kwargs + + +def test_returns_geometry_dataclass(): + """Compute returns the documented dataclass shape.""" + result = compute_wall_mounted_handrail_geometry(railing_path=_straight_path(), **_common_kwargs()) + assert isinstance(result, WallMountedHandrailGeometry) + assert isinstance(result.handrail_polyline, np.ndarray) + assert result.handrail_polyline.ndim == 2 + assert result.handrail_polyline.shape[1] == 3 + assert isinstance(result.handrail_arc_point_indices, list) + assert isinstance(result.supports, list) + assert result.handrail_radius == pytest.approx(0.025) # diameter / 2 + + +def test_no_ifc_dependency(): + """The compute function takes no ``ifcopenshell.file`` and creates no entities. + + Asserts the signature has no required ``file`` parameter — i.e. it can be + called from contexts that do not have an IFC file at all (e.g. Bonsai + viewport preview). + """ + import inspect + + sig = inspect.signature(compute_wall_mounted_handrail_geometry) + assert "file" not in sig.parameters + assert "context" not in sig.parameters + + +def test_handrail_radius_is_half_diameter(): + """The returned handrail_radius equals diameter / 2.""" + result = compute_wall_mounted_handrail_geometry( + railing_path=_straight_path(), **_common_kwargs(railing_diameter=0.080) + ) + assert result.handrail_radius == pytest.approx(0.040) + + +def test_auto_supports_count_along_straight_path(): + """A 2m straight path at 1m support spacing yields 3 automatic supports. + + ``compute_wall_mounted_handrail_geometry`` adds one support every + ``support_spacing`` along each edge, starting offset half-spacing in. + For a 2m edge: ``divmod(2.0, 1.0) == (2, 0)``, ``n_supports = 2 + 1 = 3``. + """ + result = compute_wall_mounted_handrail_geometry( + railing_path=_straight_path(length=2.0), **_common_kwargs(support_spacing=1.0) + ) + assert len(result.supports) == 3 + + +def test_manual_supports_skipped_on_straight_path(): + """Manual supports only land on non-collinear vertices. + + A 2-point straight path has no internal vertices, so manual-supports mode + produces zero supports. + """ + result = compute_wall_mounted_handrail_geometry( + railing_path=_straight_path(), **_common_kwargs(use_manual_supports=True) + ) + assert result.supports == [] + + +def test_manual_supports_on_corner(): + """An L-shaped path under manual-supports mode places one support at the corner.""" + result = compute_wall_mounted_handrail_geometry(railing_path=_l_path(), **_common_kwargs(use_manual_supports=True)) + # The corner vertex is non-collinear so it does NOT receive a manual support + # (manual supports are placed on *collinear* internal vertices, i.e. spaced + # vertices along otherwise straight runs — see ``collect_supports``). + # The L-path has only the corner as an internal vertex, which is non-collinear, + # so no manual supports are produced. This pins the documented behaviour. + assert result.supports == [] + + +def test_support_shape(): + """Each support is described by an arc polyline + a disk extrusion.""" + result = compute_wall_mounted_handrail_geometry(railing_path=_straight_path(), **_common_kwargs()) + assert len(result.supports) >= 1 + support = result.supports[0] + assert isinstance(support, RailingSupport) + # 3-point arc polyline + assert support.arc_polyline.shape == (3, 3) + # disk position coincides with the arc endpoint + np.testing.assert_allclose(support.disk_position, support.arc_polyline[-1]) + assert support.arc_radius > 0 + assert support.disk_radius > 0 + assert support.disk_depth > 0 + + +@pytest.mark.parametrize( + "terminal_type", + ["180", "TO_END_POST", "TO_WALL", "TO_FLOOR", "TO_END_POST_AND_FLOOR", "NONE"], +) +def test_all_terminal_types_produce_valid_geometry(terminal_type): + """All terminal types execute without error and produce a valid handrail polyline.""" + result = compute_wall_mounted_handrail_geometry( + railing_path=_straight_path(), **_common_kwargs(terminal_type=terminal_type) + ) + assert result.handrail_polyline.shape[0] >= 2 + assert all(0 <= idx < len(result.handrail_polyline) for idx in result.handrail_arc_point_indices) + + +def test_terminal_type_none_skips_cap_generation(): + """``terminal_type="NONE"`` skips terminal-cap generation entirely. + + The "NONE" sentinel is consumed at the cap step — the polyline is left + exactly as it came out of the fillet pass, with no extra cap vertices + or cap arc-point indices appended at either end. Every other terminal + type adds at least one cap vertex per end. + """ + result_none = compute_wall_mounted_handrail_geometry( + railing_path=_straight_path(), **_common_kwargs(terminal_type="NONE") + ) + result_180 = compute_wall_mounted_handrail_geometry( + railing_path=_straight_path(), **_common_kwargs(terminal_type="180") + ) + # NONE leaves the polyline at the raw 2-point path; 180 adds caps at both ends. + assert result_none.handrail_polyline.shape[0] == 2 + assert result_none.handrail_polyline.shape[0] < result_180.handrail_polyline.shape[0] + # NONE registers no cap arc points; 180 registers one per cap (2 total). + assert result_none.handrail_arc_point_indices == [] + assert len(result_180.handrail_arc_point_indices) >= 2 + + +def test_l_path_adds_fillet_arc(): + """An L-path with a 90° turn introduces fillet arc points in the handrail polyline.""" + result = compute_wall_mounted_handrail_geometry(railing_path=_l_path(), **_common_kwargs()) + # The fillet replaces the corner vertex with three points (start, mid-arc, end), + # and registers the mid-arc index in handrail_arc_point_indices. + assert len(result.handrail_arc_point_indices) >= 1 + + +def test_looped_path_runs_without_caps(): + """A looped path skips terminal caps (no open ends to cap). + + Pins the documented behaviour: ``if not looped_path and cap_type != "NONE"`` + — caps only when not looped. The caller passes an *unclosed* sequence of + vertices; the function appends the first two points internally to compute + fillet arcs across the wrap-around. Passing an already-closed loop + (last vertex == first) produces a zero-length edge that breaks + ``np_normalized`` — the API contract is the unclosed form. + """ + # Square footprint, NOT closed (the function closes internally). + looped = [ + (0.0, 0.0, 1.0), + (2.0, 0.0, 1.0), + (2.0, 2.0, 1.0), + (0.0, 2.0, 1.0), + ] + result = compute_wall_mounted_handrail_geometry(railing_path=looped, **_common_kwargs(looped_path=True)) + # Polyline must have no NaN values — checks that the closure was clean and + # no zero-length edge sneaked into the normalisation path. + assert not np.any(np.isnan(result.handrail_polyline)) + # Looped path has 4 corners → 4 fillet arcs. + assert len(result.handrail_arc_point_indices) == 4 + + +def test_unit_scale_converts_mm_constants(): + """``unit_scale`` divides the mm-based constants so they land in project units. + + The fillet radius is hard-coded as ``mm(100) = 0.1m`` and gets divided by + ``unit_scale`` before being applied. With ``unit_scale=1000`` (i.e. project + units are millimetres) the effective fillet radius should be 0.0001 — too + small to affect the polyline noticeably — but the function must run and + produce a valid result without raising. + """ + result = compute_wall_mounted_handrail_geometry( + railing_path=[(0, 0, 1000), (2000, 0, 1000), (2000, 2000, 1000)], + support_spacing=1000.0, + railing_diameter=50.0, + clear_width=40.0, + height=1000.0, + unit_scale=1000.0, + ) + assert isinstance(result, WallMountedHandrailGeometry) + assert result.handrail_radius == pytest.approx(25.0) + + +# --------------------------------------------------------------------------- +# Collinearity precision regression guards +# --------------------------------------------------------------------------- + + +def test_collinear_subdivided_path_does_not_add_fillets(): + """Points produced by subdividing a non-axis-aligned straight edge + must be treated as collinear, even when float arithmetic pushes the + normalised dot product *above* 1.0. + + Before fix: ``collinear(d0, d1)`` was ``is_x(np_angle(d0, d1), 0)``, + where ``np_angle`` is ``arccos(dot)``. When the two direction + vectors come from a subdivided non-axis-aligned segment, the dot of + the resulting unit vectors can land at ``1.0 + 1 ulp`` due to float + arithmetic. ``arccos`` of any value > 1.0 returns NaN, ``is_x(NaN, + 0)`` is False, and the function then tries to compute a fillet at + what should be a straight run — which immediately explodes via + ``tan(near-zero)``. + + Fix: ``collinear`` now uses ``|d0 × d1|`` instead of + ``arccos(dot)``. The cross-product magnitude is computed without + going through ``arccos``, so it stays valid (and near zero) for + truly-collinear inputs regardless of which side of 1.0 the dot + product falls on. It also collapses to 0 for anti-parallel + directions, so back-and-forth paths get the same "no usable turn" + treatment. + """ + # Non-axis-aligned because axis-aligned cases happen to give an + # exact dot of 1.0 — the arccos-clamp bug only surfaces when float + # arithmetic produces a sub-ulp overshoot, which needs a direction + # whose components don't divide cleanly. + a = np.array([0.123, 0.456, 1.0]) + direction = np.array([0.6, 0.8, 0.0]) # length 1, non-axis-aligned + p0 = a + p1 = a + direction * 1.5 + p2 = a + direction * 3.0 + path = [tuple(p0), tuple(p1), tuple(p2)] + result = compute_wall_mounted_handrail_geometry(railing_path=path, **_common_kwargs()) + assert not np.any(np.isnan(result.handrail_polyline)) + assert not np.any(np.isinf(result.handrail_polyline)) + # Only the two terminal-cap fillets — the interior vertex was + # collinear and must not have introduced a third arc. + assert len(result.handrail_arc_point_indices) == 2 + + +# --------------------------------------------------------------------------- +# End-to-end IFC smoke tests — confirms the IFC wrapping still produces a +# valid IfcShapeRepresentation around the computed geometry. +# --------------------------------------------------------------------------- + + +class TestAddRailingRepresentation(test.bootstrap.IFC4): + def setup_context(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix=None) + ifcopenshell.api.unit.assign_unit(self.file, [unit]) + model_context = ifcopenshell.api.context.add_context(self.file, context_type="Model") + self.body = ifcopenshell.api.context.add_context( + self.file, + context_type="Model", + context_identifier="Body", + target_view="MODEL_VIEW", + parent=model_context, + ) + + def test_default_railing_returns_shape_representation(self): + """End-to-end smoke: a default-args call returns a valid IfcShapeRepresentation + with one item per support plus the main handrail solid.""" + self.setup_context() + representation = ifcopenshell.api.geometry.add_railing_representation( + self.file, + context=self.body, + railing_path=[(0.0, 0.0, 1.0), (2.0, 0.0, 1.0)], + ) + assert representation.is_a("IfcShapeRepresentation") + # Items: 2 per support (arc swept-disk + floor disk extrusion) + 1 handrail swept disk + assert len(representation.Items) >= 3 + # Final item must be the handrail itself (a swept-disk solid) + assert representation.Items[-1].is_a("IfcSweptDiskSolid") diff --git a/src/ifcopenshell-python/test/test_express_aggregate_bounds.py b/src/ifcopenshell-python/test/test_express_aggregate_bounds.py new file mode 100644 index 0000000000..24b81fda5b --- /dev/null +++ b/src/ifcopenshell-python/test/test_express_aggregate_bounds.py @@ -0,0 +1,74 @@ +import os +import sys +import tempfile +import unittest + +import ifcopenshell.express + +sys.path.insert(0, os.path.dirname(ifcopenshell.express.__file__)) + + +def _parse(schema_text): + with tempfile.NamedTemporaryFile(mode="w", suffix=".exp", delete=False) as f: + f.write(schema_text) + path = f.name + try: + return ifcopenshell.express.parse(path) + finally: + os.unlink(path) + cache = path + ".cache.dat" + if os.path.exists(cache): + os.unlink(cache) + + +class TestAggregateBounds(unittest.TestCase): + def test_literal_bounds_preserved(self): + """After loading [1;3] -> (1, 3)?""" + s = _parse("SCHEMA t; ENTITY E; v : ARRAY [1:3] OF REAL; END_ENTITY; END_SCHEMA;") + agg = ( + next(d for d in s.schema.declarations() if d.name() == "E") + .attributes()[0] + .type_of_attribute() + .as_aggregation_type() + ) + self.assertEqual((agg.bound1(), agg.bound2()), (1, 3)) + s.disown() + + def test_unbounded_marker(self): + """[0:?] -> (0, -1)?""" + s = _parse("SCHEMA t; ENTITY E; v : LIST [0:?] OF REAL; END_ENTITY; END_SCHEMA;") + agg = ( + next(d for d in s.schema.declarations() if d.name() == "E") + .attributes()[0] + .type_of_attribute() + .as_aggregation_type() + ) + # import pdb; pdb.set_trace() + self.assertEqual((agg.bound1(), agg.bound2()), (0, -1)) + s.disown() + + def test_voxel_grid_with_dynamic_bound_loads(self): + """ + Array that is an expression : [1:dim_x*dim_y*dim_z] + Parsing must not crash, Bbund must be (1, -1) + """ + s = _parse(""" + SCHEMA t; + TYPE IfcBoolean = BOOLEAN; END_TYPE; + + ENTITY IfcVoxelHolder; + NumberOfVoxelsX : INTEGER; + NumberOfVoxelsY : INTEGER; + NumberOfVoxelsZ : INTEGER; + Voxels : ARRAY [1:NumberOfVoxelsX*NumberOfVoxelsY*NumberOfVoxelsZ] OF IfcBoolean; + END_ENTITY; + END_SCHEMA; + """) + holder = next(d for d in s.schema.declarations() if d.name() == "IfcVoxelHolder") + voxels = holder.attributes()[-1].type_of_attribute().as_aggregation_type() + self.assertEqual((voxels.bound1(), voxels.bound2()), (1, -1)) + s.disown() + + +if __name__ == "__main__": + unittest.main() diff --git a/src/ifcopenshell-python/test/util/test_cost.py b/src/ifcopenshell-python/test/util/test_cost.py new file mode 100644 index 0000000000..d0f9612673 --- /dev/null +++ b/src/ifcopenshell-python/test/util/test_cost.py @@ -0,0 +1,51 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2021 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 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 +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + + +import ifcopenshell.api.control +import ifcopenshell.api.cost +import test.bootstrap +import ifcopenshell.api.root + +import ifcopenshell.util.cost as subject + + +class TestGetCostItemForProduct(test.bootstrap.IFC4): + def test_run(self): + model = self.file + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + cost_schedule = ifcopenshell.api.cost.add_cost_schedule(model) + item1 = ifcopenshell.api.cost.add_cost_item(model, cost_schedule=cost_schedule) + ifcopenshell.api.control.assign_control(model, related_objects=[element], relating_control=item1) + assert list(subject.get_cost_items_for_product(element)) == [item1] + + def test_remove_cost_item(self): + model = self.file + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + cost_schedule = ifcopenshell.api.cost.add_cost_schedule(model) + item1 = ifcopenshell.api.cost.add_cost_item(model, cost_schedule=cost_schedule) + ifcopenshell.api.control.assign_control(model, related_objects=[element], relating_control=item1) + ifcopenshell.api.cost.remove_cost_item(model, cost_item=item1) + assert list(subject.get_cost_items_for_product(element)) == [] + + def test_no_assigned_cost_items(self): + model = self.file + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + cost_schedule = ifcopenshell.api.cost.add_cost_schedule(model) + item1 = ifcopenshell.api.cost.add_cost_item(model, cost_schedule=cost_schedule) + assert list(subject.get_cost_items_for_product(element)) == [] diff --git a/src/ifcopenshell-python/test/util/test_unit.py b/src/ifcopenshell-python/test/util/test_unit.py index ee749631b9..c0c967dae9 100644 --- a/src/ifcopenshell-python/test/util/test_unit.py +++ b/src/ifcopenshell-python/test/util/test_unit.py @@ -32,6 +32,17 @@ import test.bootstrap from ifcopenshell.util.shape_builder import ShapeBuilder +class TestMmToM: + def test_converts_a_positive_value(self): + assert subject.mm_to_m(150) == 0.15 + + def test_returns_zero_for_zero(self): + assert subject.mm_to_m(0) == 0.0 + + def test_passes_through_negative_values(self): + assert subject.mm_to_m(-25) == -0.025 + + class TestCacheUnits(test.bootstrap.IFC4): def test_run(self): ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") diff --git a/src/ifcparse/CMakeLists.txt b/src/ifcparse/CMakeLists.txt index 7d3c4db3d1..4c07174089 100644 --- a/src/ifcparse/CMakeLists.txt +++ b/src/ifcparse/CMakeLists.txt @@ -57,7 +57,7 @@ else() endif() if(WITH_ROCKSDB) - target_link_libraries(IfcParse RocksDB::rocksdb) + target_link_libraries(IfcParse ${IFCOPENSHELL_ROCKSDB_TARGET}) if(WITH_ZSTD) target_link_libraries(IfcParse zstd::libzstd_static) endif() diff --git a/src/ifcparse/character_decoder.cpp b/src/ifcparse/character_decoder.cpp index 89b96156cc..2b3c583c70 100644 --- a/src/ifcparse/character_decoder.cpp +++ b/src/ifcparse/character_decoder.cpp @@ -288,7 +288,7 @@ namespace { if (character >= 0x20 && character <= 0x7e) { stream.put((char)character); } else { - stream << "\\u" << character; + stream << "\\u" << static_cast(character); } }); return stream.str(); diff --git a/src/ifcparse/express.cpp b/src/ifcparse/express.cpp index a6503c4b56..71df55387b 100644 --- a/src/ifcparse/express.cpp +++ b/src/ifcparse/express.cpp @@ -9,19 +9,27 @@ uint32_t express::Base::identity() const { return data()->identity(); } uint32_t express::Base::id() const { return data()->id(); } const instance_data* express::Base::data() const { +#ifdef IFOPSH_SAFE_INSTANCE auto sp = data_.lock(); if (sp) { return sp.get(); } else { throw std::runtime_error("Trying to access deleted instance reference"); } +#else + return data_; +#endif } instance_data* express::Base::data() { +#ifdef IFOPSH_SAFE_INSTANCE auto sp = data_.lock(); if (sp) { return sp.get(); } else { throw std::runtime_error("Trying to access deleted instance reference"); } +#else + return data_; +#endif } diff --git a/src/ifcparse/express.h b/src/ifcparse/express.h index e4e2dd699d..abb501a1d8 100644 --- a/src/ifcparse/express.h +++ b/src/ifcparse/express.h @@ -31,6 +31,23 @@ class aggregate_of_instance; namespace ifcopenshell { + +#ifdef IFOPSH_SAFE_INSTANCE +using pointer_type = std::weak_ptr; +using shared_pointer_type = shared_pointer_type; +template +shared_pointer_type make_pointer_type(Args&&... args) { + return std::make_shared(std::forward(args)...); +} +#else +using pointer_type = instance_data*; +using shared_pointer_type = instance_data*; +template +shared_pointer_type make_pointer_type(Args&&... args) { + return new T(std::forward(args)...); +} +#endif + class file; namespace impl { struct in_memory_file_storage; @@ -49,12 +66,16 @@ class DeclaredType; class IFC_PARSE_API Base { protected: - std::weak_ptr data_; + ifcopenshell::pointer_type data_; const instance_data* data() const; instance_data* data(); public: operator bool() const { +#ifdef IFOPSH_SAFE_INSTANCE return !data_.expired(); +#else + return data_ != nullptr; +#endif } bool operator<(const Base& other) const { @@ -69,12 +90,16 @@ class IFC_PARSE_API Base { return !(*this == other); } - Base() {}; + Base() { +#ifndef IFOPSH_SAFE_INSTANCE + data_ = nullptr; +#endif + }; Base(std::nullopt_t) noexcept : Base() {} - Base(const std::weak_ptr& data) : data_(data) {} + Base(const ifcopenshell::pointer_type& data) : data_(data) {} // @todo try and make this private over time too - const std::weak_ptr& data_weak() const { return data_; } + const ifcopenshell::pointer_type& data_weak() const { return data_; } const ifcopenshell::declaration& declaration() const; @@ -150,7 +175,7 @@ class IFC_PARSE_API Select : public Base { public: Select() {} Select(std::nullopt_t) noexcept : Base() {} - Select(const std::weak_ptr& data) : Base(data) {} + Select(const ifcopenshell::pointer_type& data) : Base(data) {} Select(const Base& base) : Base(base.data_weak()) {} Base concrete() const { diff --git a/src/ifcparse/file.cpp b/src/ifcparse/file.cpp index c5a4012bda..652be45adc 100644 --- a/src/ifcparse/file.cpp +++ b/src/ifcparse/file.cpp @@ -4,341 +4,14 @@ #ifdef IFOPSH_WITH_ROCKSDB #include #include +#include #endif #include +#include #include #include - -ifcopenshell::parse_context::~parse_context() { - for (auto& t : tokens_) { - std::visit([](auto& v) { - if constexpr (std::is_same_v, parse_context*>) { - delete v; - } - }, t); - } -} - -ifcopenshell::parse_context& ifcopenshell::parse_context::push() { - auto* pc = new parse_context; - tokens_.push_back(pc); - return *pc; -} - -void ifcopenshell::parse_context::push(token t) { - tokens_.push_back(t); -} - -void ifcopenshell::parse_context::push(const express::Base& inst) { - tokens_.push_back(inst); -} - -namespace { - template - struct is_type_in_variant; - - // Specialization when there are multiple types in the variant - template - struct is_type_in_variant, T> - { - static constexpr bool value = std::is_same::value || is_type_in_variant, T>::value; - }; - - // Specialization when there is only one type left in the variant - template - struct is_type_in_variant, T> - { - static constexpr bool value = std::is_same::value; - }; - - template - constexpr bool is_type_in_variant_v = is_type_in_variant::value; - - template - void dispatch_token(std::optional instance_id, int attribute_id, ifcopenshell::token t, ifcopenshell::declaration* decl, Fn fn) { - if (t.is_binary()) { - fn(t.as_binary()); - } else if (t.is_bool()) { - fn(t.as_bool()); - } else if (t.is_logical()) { - fn(t.as_logical()); - } else if (t.is_enumeration()) { - const auto& s = t.as_string(); - if (decl && decl->as_enumeration_type()) { - try { - fn(enumeration_reference(decl->as_enumeration_type(), decl->as_enumeration_type()->lookup_enum_offset(s))); - } catch (ifcopenshell::exception& e) { - logger::error("An enumeration literal '" + s + "' is not valid for type '" + decl->name() + "' at offset " + std::to_string(t.start_pos)); - } - } else { - logger::error("An enumeration literal '" + s + "' is not expected at attribute index '" + std::to_string(attribute_id) + "' at offset " + std::to_string(t.start_pos)); - } - } else if (t.is_int()) { - // @nb make sure is_int() comes before is_float() - fn(t.as_int()); - } else if (t.is_float()) { - fn(t.as_float()); - } else if (t.is_identifier()) { - fn(ifcopenshell::reference_or_simple_type{ifcopenshell::instance_reference{(int) t.as_identifier(), t.start_pos}}); - } else if (t.is_string()) { - fn(t.as_string()); - } else if (t.is_operator('*')) { - // This is only in place for the validator - fn(derived{}); - } - } - - template - void construct_(std::optional instance_id, int attribute_id, ifcopenshell::parse_context& p, const ifcopenshell::aggregation_type* aggr, Fn fn) { - if (p.tokens_.empty()) { - // @todo instead of ugly if-else we could also default initialize the respective - // variant types below. - if (aggr) { - auto aggr_type = ifcopenshell::make_aggregate(ifcopenshell::from_parameter_type(aggr->type_of_element())); - if (aggr_type == ifcopenshell::Argument_AGGREGATE_OF_INT) { - fn(std::vector{}); - } else if (aggr_type == ifcopenshell::Argument_AGGREGATE_OF_DOUBLE) { - fn(std::vector{}); - } else if (aggr_type == ifcopenshell::Argument_AGGREGATE_OF_STRING) { - fn(std::vector{}); - } else if (aggr_type == ifcopenshell::Argument_AGGREGATE_OF_BINARY) { - fn(std::vector>{}); - } else if (aggr_type == ifcopenshell::Argument_AGGREGATE_OF_ENTITY_INSTANCE) { - fn(std::vector{}); - } else if (aggr_type == ifcopenshell::Argument_AGGREGATE_OF_AGGREGATE_OF_INT) { - fn(std::vector>{}); - } else if (aggr_type == ifcopenshell::Argument_AGGREGATE_OF_AGGREGATE_OF_DOUBLE) { - fn(std::vector>{}); - } else if (aggr_type == ifcopenshell::Argument_AGGREGATE_OF_AGGREGATE_OF_ENTITY_INSTANCE) { - fn(std::vector>{}); - } - } - return; - } - - typedef std::variant< - blank, - - std::vector, - std::vector, - std::vector, - std::vector>, - std::vector, - - std::vector>, - std::vector>, - std::vector> - > possible_aggregation_types_t; - - possible_aggregation_types_t aggregate_storage; - - auto append_to_aggregate_storage = [&aggregate_storage](const auto& v) { - if constexpr (is_type_in_variant_v>>) { - if (aggregate_storage.index() == 0) { - aggregate_storage = std::vector>{ v }; - } else { - if (auto* vec_ptr = std::get_if>>(&aggregate_storage)) { - vec_ptr->push_back(v); - } else { - if constexpr (std::is_same_v, int>) { - auto* vec_ptr2 = std::get_if>(&aggregate_storage); - if (vec_ptr2) { - // double[] + int - vec_ptr2->push_back((double) v); - } - } - if constexpr (std::is_same_v, double>) { - auto* vec_ptr2 = std::get_if>(&aggregate_storage); - if (vec_ptr2) { - // int[] -> double[] + double - std::vector ps(vec_ptr2->begin(), vec_ptr2->end()); - ps.push_back(v); - aggregate_storage = ps; - } - } - - if constexpr (std::is_same_v, std::vector>) { - auto* vec_ptr2 = std::get_if>>(&aggregate_storage); - if (vec_ptr2) { - // double[][] + int[] - std::vector vd(v.begin(), v.end()); - vec_ptr2->push_back(vd); - } - } - if constexpr (std::is_same_v, std::vector>) { - auto* vec_ptr2 = std::get_if>>(&aggregate_storage); - if (vec_ptr2) { - // int[][] -> double[][] + double[] - std::vector> vvd; - for (auto& vv : *vec_ptr2) { - std::vector vd(vv.begin(), vv.end()); - vvd.push_back(vd); - } - vvd.push_back(v); - aggregate_storage = vvd; - } - } - - // @todo would be cool if we can trace this back to file offset - auto current = std::visit([](auto v) { - if constexpr (!std::is_same_v) { - return std::string(typeid(typename decltype(v)::value_type).name()); - } else { - // Cannot occur as aggregate_storage.which() == 0 - // is another branch several statements up. But is - // needed for consistency of return type. - return std::string{}; - } - }, aggregate_storage); - - logger::error("Inconsistent aggregate valuation while attempting to append " + std::string(typeid(decltype(v)).name()) + " to an aggregate of " + current); - - // @todo boolean -> logical upgrade - // wait a second... there are no aggregate of bool / logical in the schema.. - // - // if constexpr (std::is_same_v, bool>) { - // auto* vec_ptr = boost::get(&aggregate_storage); - // vec_ptr->push_back(v); - // } - // if constexpr (std::is_same_v, boost::tribool>) { - // auto* vec_ptr = boost::get(&aggregate_storage); - // std::vector ps(vec_ptr->begin(), vec_ptr->end()); - // ps.push_back(v); - // aggregate_storage = ps; - // } - } - } - } else { - // @todo would be cool if we can trace this back to file offset - logger::error(std::string("Aggregates of ") + typeid(decltype(v)).name() + " are not supported in the IfcOpenShell parser"); - } - }; - - for (auto& t : p.tokens_) { - std::visit([&aggregate_storage, &append_to_aggregate_storage, aggr, instance_id, attribute_id](const auto& v) { - if constexpr (std::is_same_v, ifcopenshell::token>) { - // @todo get aggregate of enumeration - dispatch_token(instance_id, attribute_id, v, aggr && aggr->type_of_element()->as_named_type() ? aggr->type_of_element()->as_named_type()->declared_type() : nullptr, append_to_aggregate_storage); - } else if constexpr (std::is_same_v, ifcopenshell::parse_context*>) { - // nested list - if constexpr (Depth < 3) { - construct_(instance_id, attribute_id, *v, nullptr, append_to_aggregate_storage); - } - } else { - append_to_aggregate_storage(ifcopenshell::reference_or_simple_type{ v }); - } - }, t); - } - - std::visit(fn, aggregate_storage); - } -} - -std::shared_ptr ifcopenshell::parse_context::construct(ifcopenshell::file* owner, std::optional name, unresolved_references& references_to_resolve, const ifcopenshell::declaration* decl, std::optional expected_size, int resolve_reference_index, bool coerce_attribute_count) { - std::vector parameter_types; - std::unique_ptr transient_named_type; - - if ((decl != nullptr) && (decl->as_type_declaration() != nullptr)) { - parameter_types = { decl->as_type_declaration()->declared_type() }; - } else if ((decl != nullptr) && (decl->as_enumeration_type() != nullptr)) { - transient_named_type.reset(new ifcopenshell::named_type(const_cast(decl))); - parameter_types = { &*transient_named_type }; - } else if ((decl != nullptr) && (decl->as_entity() != nullptr)) { - const auto& entity_attrs = decl->as_entity()->all_attributes(); - std::transform( - entity_attrs.begin(), - entity_attrs.end(), - std::back_inserter(parameter_types), - [](auto* attr) { - return attr->type_of_attribute(); - } - ); - } - - if (((decl != nullptr) && (tokens_.size() != parameter_types.size())) || - expected_size && *expected_size != tokens_.size()) - { - size_t expected = expected_size ? *expected_size : parameter_types.size(); - if (decl != nullptr && decl->schema() == &Header_section_schema::get_schema()) { - logger::warning("Expected " + std::to_string(expected) + " attribute values, found " + std::to_string(tokens_.size()) + " for header entity " + decl->name()); - } else { - logger::warning("Expected " + std::to_string(expected) + " attribute values, found " + std::to_string(tokens_.size()) + (name ? std::string(" for instance #" + std::to_string(*name)) : std::string(""))); - } - } - - if (tokens_.empty()) { - return std::make_shared(owner, decl, name.value_or(0), in_memory_attribute_storage(0)); - } - - in_memory_attribute_storage storage(coerce_attribute_count - ? (decl != nullptr - ? (std::min)(parameter_types.size(), tokens_.size()) - : tokens_.size()) - : tokens_.size() - ); - - auto it = tokens_.begin(); - auto kt = parameter_types.begin(); - for (; it != tokens_.end() && ((decl == nullptr) || kt != parameter_types.end()); ++it) { - auto& token = *it; - // @todo coerce to expected type, e.g empty -> std::vector, bool -> logical - const ifcopenshell::parameter_type* param_type = nullptr; - if (decl != nullptr) { - param_type = *kt; - } - - auto index = (uint8_t) std::distance(tokens_.begin(), it); - - std::visit([this, &storage, name, &references_to_resolve, index, param_type, resolve_reference_index](const auto& v) { - if constexpr (std::is_same_v, ifcopenshell::token>) { - dispatch_token(name, index, v, param_type && param_type->as_named_type() ? param_type->as_named_type()->declared_type() : nullptr, [this, &storage, name, &references_to_resolve, index, resolve_reference_index](auto v) { - if constexpr (std::is_same_v, ifcopenshell::reference_or_simple_type>) { - if (name) { - references_to_resolve.push_back(std::make_pair( - // @todo previously this was storage but apparently the - // pointer is not constant with the moving and temporary nature - // maybe it ought to be and in that case a pointer is more direct - mutable_attribute_value{ (uint32_t) *name, resolve_reference_index == -1 ? index : (uint8_t) resolve_reference_index }, - v - )); - } - } else { - storage.set(index, v); - } - }); - } else if constexpr (std::is_same_v, ifcopenshell::parse_context*>) { - const auto *pt = param_type; - if (pt) { - while (pt->as_named_type() && pt->as_named_type()->declared_type()->as_type_declaration()) { - pt = pt->as_named_type()->declared_type()->as_type_declaration()->declared_type(); - } - } - construct_<0>(name, index, *v, pt ? pt->as_aggregation_type() : nullptr, [this, &storage, name, &references_to_resolve, index, resolve_reference_index](const auto& v) { - if constexpr (std::is_same_v, std::vector>) { - if (name) { - references_to_resolve.push_back({ { (uint32_t) *name, resolve_reference_index == -1 ? index : (uint8_t)resolve_reference_index }, v }); - } - } else if constexpr (std::is_same_v, std::vector>>) { - if (name) { - references_to_resolve.push_back({ { (uint32_t) *name, resolve_reference_index == -1 ? index : (uint8_t)resolve_reference_index }, v }); - } - } else { - storage.set(index, v); - } - }); - } else { - storage.set(index, v); - } - }, token); - - if (decl != nullptr) { - ++kt; - } - } - - return std::make_shared(owner, decl, (decl && decl->as_entity()) ? name.value_or(0) : 0, std::move(storage)); -} +#include /* ifcopenshell::IfcBaseClass* ifcopenshell::impl::rocks_db_file_storage::rocksdb_instance_iterator::operator*() const { @@ -391,7 +64,7 @@ express::Base ifcopenshell::impl::rocks_db_file_storage::assert_existance(size_t } // @nb note that in case of type declarations we pass the identity as the number so // that we can read back the attributes from the db (we cannot assign to identity). - auto data = std::make_shared(file, decl, number, rocks_db_attribute_storage{}); + auto data = ifcopenshell::make_pointer_type(file, decl, number, rocks_db_attribute_storage{}); if (r == ifcopenshell::impl::rocks_db_file_storage::entityinstance_ref) { instance_cache_.insert({number, data}); } else { @@ -407,10 +80,8 @@ express::Base ifcopenshell::impl::rocks_db_file_storage::assert_existance(size_t } namespace { - rocksdb::DB* init_db(const std::string& filepath, bool readonly) { - rocksdb::DB* db = nullptr; + std::unique_ptr init_db(const std::string& filepath, bool readonly) { #ifdef IFOPSH_WITH_ROCKSDB - rocksdb::Options options; // options.disable_auto_compactions = true; options.create_if_missing = true; @@ -442,16 +113,31 @@ namespace { options.table_factory.reset(rocksdb::NewBlockBasedTableFactory(tbo)); rocksdb::Status status; + std::unique_ptr db; if (readonly) { +#if ROCKSDB_MAJOR > 9 || (ROCKSDB_MAJOR == 9 && ROCKSDB_MINOR >= 11) status = rocksdb::DB::OpenForReadOnly(options, filepath, &db); +#else + rocksdb::DB* raw = nullptr; + status = rocksdb::DB::OpenForReadOnly(options, filepath, &raw); + db.reset(raw); +#endif } else { +#if ROCKSDB_MAJOR > 9 || (ROCKSDB_MAJOR == 9 && ROCKSDB_MINOR >= 11) status = rocksdb::DB::Open(options, filepath, &db); +#else + rocksdb::DB* raw = nullptr; + status = rocksdb::DB::Open(options, filepath, &raw); + db.reset(raw); +#endif } if (!status.ok()) { return nullptr; } -#endif // IFOPSH_WITH_ROCKSDB# return db; +#else + return nullptr; +#endif } } @@ -460,12 +146,12 @@ ifcopenshell::impl::rocks_db_file_storage::rocks_db_file_storage(const std::stri : file(ffile) , db(init_db(filepath, readonly)) // @todo streaming serializer does not populate the byguid map - , byguid_internal_(db, "g|"), + , byguid_internal_(db.get(), "g|"), byguid_(&byguid_internal_, [this](size_t v) { return assert_existance(v, entityinstance_ref); }, [](const express::Base& v) { return v.identity(); }) - , instance_ids_(db, "i|") + , instance_ids_(db.get(), "i|") , instance_by_name_(&instance_ids_, [this](size_t v) { return assert_existance(v, entityinstance_ref); }) - , bytype_(db, "t|") - , byref_excl_(db, "v|") + , bytype_(db.get(), "t|") + , byref_excl_(db.get(), "v|") // @todo by_identity is probably not correct here, this mapping is Name -> Identity, so Fn should have access to full pair? // , byidentity_(&byid_, [this](size_t v) { return assert_existance(v, by_identity); }, [](ifcopenshell::IfcBaseClass* v) { return v->identity(); }) { @@ -492,7 +178,6 @@ ifcopenshell::impl::rocks_db_file_storage::~rocks_db_file_storage() } db->Close(); - delete db; } #endif } @@ -674,7 +359,7 @@ express::Base ifcopenshell::impl::in_memory_file_storage::create(const ifcopensh } else { throw std::runtime_error("Requires and entity or type declaration"); } - auto data = std::make_shared(file, decl, instance_name, decl->as_entity() ? in_memory_attribute_storage(decl->as_entity()->attribute_count()) : in_memory_attribute_storage(1)); + auto data = ifcopenshell::make_pointer_type(file, decl, instance_name, decl->as_entity() ? in_memory_attribute_storage(decl->as_entity()->attribute_count()) : in_memory_attribute_storage(1)); if (instance_name) { byid_.insert({instance_name, data}); } else { diff --git a/src/ifcparse/file.h b/src/ifcparse/file.h index 9be7803a6f..ecc823e156 100644 --- a/src/ifcparse/file.h +++ b/src/ifcparse/file.h @@ -35,6 +35,7 @@ #include #include #include +#include #ifdef IFOPSH_WITH_ROCKSDB @@ -107,6 +108,7 @@ private: bool yield_header_instances_ = true; std::vector types_to_bypass_; std::vector bypassed_instances_; + std::vector types_to_bypass_materialized_; void initialize_header(); spf_header& ensure_header(); @@ -143,7 +145,7 @@ private: return storage_.byref_excl_; } - std::vector> steal_instances() { + std::vector steal_instances() { return storage_.steal_instances(); } @@ -171,7 +173,7 @@ private: ~instance_streamer() = default; - std::optional>> read_instance(); + std::optional> read_instance(); }; class uninitialized_tag {}; diff --git a/src/ifcparse/hierarchy_helper.h b/src/ifcparse/hierarchy_helper.h index 52e15d14c7..1f72efd1ee 100644 --- a/src/ifcparse/hierarchy_helper.h +++ b/src/ifcparse/hierarchy_helper.h @@ -201,7 +201,7 @@ class IFC_SCHEMA_API hierarchy_helper : public ifcopenshell::file { t.set_attribute_value(1, owner_history); int relating_index = 4; int related_index = 5; - if (T::Class().name() == "IfcRelContainedInSpatialStructure" || std::is_base_of::value) { + if (T::Class().name() == "IfcRelContainedInSpatialStructure" || T::Class().name() == "IfcRelReferencedInSpatialStructure" || std::is_base_of::value) { // some classes have attributes reversed. std::swap(relating_index, related_index); } diff --git a/src/ifcparse/instance_data.h b/src/ifcparse/instance_data.h index d80d9d2d0c..d41265f221 100644 --- a/src/ifcparse/instance_data.h +++ b/src/ifcparse/instance_data.h @@ -37,6 +37,11 @@ #endif +#include +#include + +#include +#include #include #include diff --git a/src/ifcparse/parse.cpp b/src/ifcparse/parse.cpp index a60adb883f..1b02dc3cc5 100644 --- a/src/ifcparse/parse.cpp +++ b/src/ifcparse/parse.cpp @@ -39,6 +39,7 @@ #include #include #include +#include #ifdef USE_MMAP #include @@ -48,60 +49,6 @@ using namespace ifcopenshell; -// A static locale for the real number parser. strtod() is locale-dependent, causing issues -// in locales that have ',' as a decimal separator. Therefore the non standard _strtod_l() / -// strtod_l() is used and a reference to the "C" locale is obtained here. The alternative is -// to use std::istringstream::imbue(std::locale::classic()), but there are subtleties in -// parsing in MSVC2010 and it appears to be much slower. -#if defined(_MSC_VER) - -static _locale_t locale = (_locale_t)0; -void init_locale() { - if (locale == (_locale_t)0) { - locale = _create_locale(LC_NUMERIC, "C"); - } -} - -#else - -#if defined(__MINGW64__) || defined(__MINGW32__) -#include -#include - -typedef void* locale_t; -static locale_t locale = (locale_t)0; - -void init_locale() {} - -double strtod_l(const char* start, char** end, locale_t loc) { - double d; - std::stringstream ss; - ss.imbue(std::locale::classic()); - ss << start; - ss >> d; - size_t nread = ss.tellg(); - *end = const_cast(start) + nread; - return d; -} - -#else - -#ifdef __APPLE__ -#include -#endif -#include - -static locale_t locale = (locale_t)0; -void init_locale() { - if (locale == (locale_t)0) { - locale = newlocale(LC_NUMERIC_MASK, "C", (locale_t)0); - } -} - -#endif - -#endif - template spf_lexer::spf_lexer(Reader* stream_) { stream = stream_; @@ -174,27 +121,22 @@ std::string& spf_lexer::get_temp_string() const { namespace { -bool parse_int_(const char* pStart, int& val) { - char* pEnd; - long result = strtol(pStart, &pEnd, 10); - if (*pEnd != 0) { +template +bool parse_num_(const char* pStart, size_t size, T& val) { + if (size == 0) { return false; } - val = (int)result; - return true; -} - -bool parse_float_(const char* pStart, double& val) { - char* pEnd; -#ifdef _MSC_VER - double result = _strtod_l(pStart, &pEnd, locale); -#else - double result = strtod_l(pStart, &pEnd, locale); -#endif - if (*pEnd != 0) { + if (*pStart == '+') { + ++pStart; + --size; + if (size == 0) { + return false; + } + } + auto re = std::from_chars(pStart, pStart + size, val); + if (re.ec != std::errc() || re.ptr != pStart + size) { return false; } - val = result; return true; } @@ -382,7 +324,7 @@ token spf_lexer::next() { return token(pos, token::Token_BOOL, str[0]); } else if (ttype == token::Token_IDENTIFIER) { int int_val; - if (!parse_int_(str.c_str(), int_val)) { + if (!parse_num_(str.c_str(), str.size(), int_val)) { throw invalid_token_exception(pos, str, "instance name"); } pop_pool_entry(); @@ -394,11 +336,11 @@ token spf_lexer::next() { if ((first >= 'A' && first <= 'Z') || (first >= 'a' && first <= 'z')) { ttype = token::Token_KEYWORD; return token(pos, ttype, str); - } else if (parse_int_(str.c_str(), int_val)) { + } else if (parse_num_(str.c_str(), str.size(), int_val)) { ttype = token::Token_INT; pop_pool_entry(); return token(pos, ttype, int_val); - } else if (parse_float_(str.c_str(), float_val)) { + } else if (parse_num_(str.c_str(), str.size(), float_val)) { ttype = token::Token_FLOAT; pop_pool_entry(); return token(pos, float_val); @@ -572,61 +514,490 @@ std::string token::to_string() { return result; } -// -// Reads the arguments from a list of token -// Aditionally, registers the ids (i.e. #[\d]+) in the inverse map -// -template -void ifcopenshell::impl::in_memory_file_storage::load(ifcopenshell::spf_lexer* tokens, std::optional entity_instance_name, const ifcopenshell::entity* entity, parse_context& context, int attribute_index) { - token next = tokens->next(); +namespace { - size_t attribute_index_within_data = 0; - size_t return_value = 0; +template +struct is_type_in_variant; + +template +struct is_type_in_variant, T> +{ + static constexpr bool value = std::is_same::value || is_type_in_variant, T>::value; +}; + +template +struct is_type_in_variant, T> +{ + static constexpr bool value = std::is_same::value; +}; + +template +constexpr bool is_type_in_variant_v = is_type_in_variant::value; + +class parameter_type_view { + const ifcopenshell::declaration* declaration_; + const std::vector* attributes_; + std::unique_ptr transient_named_type_; + +public: + parameter_type_view(const ifcopenshell::declaration* declaration) + : declaration_(declaration) + , attributes_(nullptr) + { + if (declaration_ && declaration_->as_entity()) { + attributes_ = &declaration_->as_entity()->all_attributes(); + } else if (declaration_ && declaration_->as_enumeration_type()) { + transient_named_type_.reset(new ifcopenshell::named_type(const_cast(declaration_))); + } + } + + size_t size() const { + if (attributes_) { + return attributes_->size(); + } + return declaration_ ? 1 : 0; + } + + const ifcopenshell::parameter_type* operator[](size_t index) const { + if (attributes_) { + return index < attributes_->size() ? (*attributes_)[index]->type_of_attribute() : nullptr; + } + if (index != 0 || !declaration_) { + return nullptr; + } + if (auto* type_declaration = declaration_->as_type_declaration()) { + return type_declaration->declared_type(); + } + if (declaration_->as_enumeration_type()) { + return transient_named_type_.get(); + } + return nullptr; + } +}; + +const ifcopenshell::parameter_type* unwrap_type_declarations(const ifcopenshell::parameter_type* parameter_type) { + while (parameter_type && parameter_type->as_named_type() && + parameter_type->as_named_type()->declared_type()->as_type_declaration()) { + parameter_type = parameter_type->as_named_type()->declared_type()->as_type_declaration()->declared_type(); + } + return parameter_type; +} + +ifcopenshell::declaration* declared_type(const ifcopenshell::parameter_type* parameter_type) { + parameter_type = unwrap_type_declarations(parameter_type); + return parameter_type && parameter_type->as_named_type() ? parameter_type->as_named_type()->declared_type() : nullptr; +} + +const ifcopenshell::aggregation_type* aggregate_parameter_type(const ifcopenshell::parameter_type* parameter_type) { + parameter_type = unwrap_type_declarations(parameter_type); + return parameter_type ? parameter_type->as_aggregation_type() : nullptr; +} + +const ifcopenshell::aggregation_type* nested_aggregation_type(const ifcopenshell::aggregation_type* aggregate_type) { + return aggregate_type ? aggregate_parameter_type(aggregate_type->type_of_element()) : nullptr; +} + +void warn_attribute_count( + const ifcopenshell::declaration* declaration, + std::optional instance_name, + size_t expected_size, + size_t actual_size +) { + if (!declaration || expected_size == actual_size) { + return; + } + if (declaration->schema() == &Header_section_schema::get_schema()) { + logger::warning("Expected " + std::to_string(expected_size) + " attribute values, found " + std::to_string(actual_size) + " for header entity " + declaration->name()); + } else { + logger::warning("Expected " + std::to_string(expected_size) + " attribute values, found " + std::to_string(actual_size) + (instance_name ? std::string(" for instance #" + std::to_string(*instance_name)) : std::string(""))); + } +} + +template +void dispatch_token_direct(ifcopenshell::token token, ifcopenshell::declaration* declaration, int attribute_index, Fn&& fn) { + if (token.is_binary()) { + fn(token.as_binary()); + } else if (token.is_bool()) { + fn(token.as_bool()); + } else if (token.is_logical()) { + fn(token.as_logical()); + } else if (token.is_enumeration()) { + const auto& value = token.as_string(); + if (declaration && declaration->as_enumeration_type()) { + try { + fn(enumeration_reference(declaration->as_enumeration_type(), declaration->as_enumeration_type()->lookup_enum_offset(value))); + } catch (ifcopenshell::exception&) { + logger::error("An enumeration literal '" + value + "' is not valid for type '" + declaration->name() + "' at offset " + std::to_string(token.start_pos)); + } + } else { + logger::error("An enumeration literal '" + value + "' is not expected at attribute index '" + std::to_string(attribute_index) + "' at offset " + std::to_string(token.start_pos)); + } + } else if (token.is_int()) { + fn(token.as_int()); + } else if (token.is_float()) { + fn(token.as_float()); + } else if (token.is_identifier()) { + fn(ifcopenshell::reference_or_simple_type{ifcopenshell::instance_reference{(int) token.as_identifier(), token.start_pos}}); + } else if (token.is_string()) { + fn(token.as_string()); + } else if (token.is_operator('*')) { + fn(derived{}); + } +} + +typedef std::variant< + blank, + + std::vector, + std::vector, + std::vector, + std::vector>, + std::vector, + + std::vector>, + std::vector>, + std::vector> +> direct_aggregate_storage; + +struct direct_aggregate { + direct_aggregate_storage storage; + size_t pending_empty_aggregates = 0; + size_t values = 0; + + template + void append(const T& value) { + ++values; + if constexpr (is_type_in_variant_v>>) { + if constexpr ( + std::is_same_v, std::vector> || + std::is_same_v, std::vector> || + std::is_same_v, std::vector> + ) { + if (storage.index() == 0 && pending_empty_aggregates) { + append_promoted(value); + return; + } + } + if (pending_empty_aggregates) { + logger::error("Inconsistent aggregate valuation while attempting to append " + std::string(typeid(T).name()) + " after an empty nested aggregate"); + pending_empty_aggregates = 0; + } + if (storage.index() == 0) { + storage = std::vector>{value}; + } else if (auto* vector = std::get_if>>(&storage)) { + vector->push_back(value); + } else { + append_promoted(value); + } + } else { + logger::error(std::string("Aggregates of ") + typeid(T).name() + " are not supported in the IfcOpenShell parser"); + } + } + + void append_empty_nested() { + ++values; + if (auto* int_vector = std::get_if>>(&storage)) { + int_vector->emplace_back(); + } else if (auto* double_vector = std::get_if>>(&storage)) { + double_vector->emplace_back(); + } else if (auto* reference_vector = std::get_if>>(&storage)) { + reference_vector->emplace_back(); + } else if (storage.index() == 0) { + ++pending_empty_aggregates; + } else { + logger::error("Inconsistent aggregate valuation while attempting to append an empty nested aggregate"); + } + } + +private: + template + void append_promoted(const T& value) { + if constexpr (std::is_same_v, int>) { + if (auto* vector = std::get_if>(&storage)) { + vector->push_back((double) value); + return; + } + } + if constexpr (std::is_same_v, double>) { + if (auto* vector = std::get_if>(&storage)) { + std::vector promoted(vector->begin(), vector->end()); + promoted.push_back(value); + storage = std::move(promoted); + return; + } + } + if constexpr (std::is_same_v, std::vector>) { + if (storage.index() == 0) { + std::vector> promoted(pending_empty_aggregates); + pending_empty_aggregates = 0; + promoted.push_back(value); + storage = std::move(promoted); + return; + } + if (auto* vector = std::get_if>>(&storage)) { + vector->push_back(value); + return; + } + if (auto* vector = std::get_if>>(&storage)) { + std::vector promoted(value.begin(), value.end()); + vector->push_back(std::move(promoted)); + return; + } + } + if constexpr (std::is_same_v, std::vector>) { + if (storage.index() == 0) { + std::vector> promoted(pending_empty_aggregates); + pending_empty_aggregates = 0; + promoted.push_back(value); + storage = std::move(promoted); + return; + } + if (auto* vector = std::get_if>>(&storage)) { + vector->push_back(value); + return; + } + if (auto* vector = std::get_if>>(&storage)) { + std::vector> promoted; + promoted.reserve(vector->size() + 1); + for (const auto& nested : *vector) { + promoted.emplace_back(nested.begin(), nested.end()); + } + promoted.push_back(value); + storage = std::move(promoted); + return; + } + } + if constexpr (std::is_same_v, std::vector>) { + if (storage.index() == 0) { + std::vector> promoted(pending_empty_aggregates); + pending_empty_aggregates = 0; + promoted.push_back(value); + storage = std::move(promoted); + return; + } + if (auto* vector = std::get_if>>(&storage)) { + vector->push_back(value); + return; + } + } + + auto current = std::visit([](auto v) { + if constexpr (!std::is_same_v) { + return std::string(typeid(typename decltype(v)::value_type).name()); + } else { + return std::string{}; + } + }, storage); + logger::error("Inconsistent aggregate valuation while attempting to append " + std::string(typeid(T).name()) + " to an aggregate of " + current); + } +}; + +void append_empty_direct_aggregate(const ifcopenshell::aggregation_type* aggregate_type, direct_aggregate& target) { + if (!aggregate_type) { + target.append_empty_nested(); + return; + } + + auto argument_type = ifcopenshell::make_aggregate(ifcopenshell::from_parameter_type(aggregate_type->type_of_element())); + if (argument_type == ifcopenshell::Argument_AGGREGATE_OF_INT) { + target.storage = std::vector{}; + } else if (argument_type == ifcopenshell::Argument_AGGREGATE_OF_DOUBLE) { + target.storage = std::vector{}; + } else if (argument_type == ifcopenshell::Argument_AGGREGATE_OF_STRING) { + target.storage = std::vector{}; + } else if (argument_type == ifcopenshell::Argument_AGGREGATE_OF_BINARY) { + target.storage = std::vector>{}; + } else if (argument_type == ifcopenshell::Argument_AGGREGATE_OF_ENTITY_INSTANCE) { + target.storage = std::vector{}; + } else if (argument_type == ifcopenshell::Argument_AGGREGATE_OF_AGGREGATE_OF_INT) { + target.storage = std::vector>{}; + } else if (argument_type == ifcopenshell::Argument_AGGREGATE_OF_AGGREGATE_OF_DOUBLE) { + target.storage = std::vector>{}; + } else if (argument_type == ifcopenshell::Argument_AGGREGATE_OF_AGGREGATE_OF_ENTITY_INSTANCE) { + target.storage = std::vector>{}; + } else { + target.append_empty_nested(); + } +} + +template +void set_direct_attribute( + in_memory_attribute_storage& storage, + std::optional instance_name, + ifcopenshell::unresolved_references* references_to_resolve, + size_t attribute_index, + int resolve_reference_index, + const T& value +) { + if constexpr (std::is_same_v, ifcopenshell::reference_or_simple_type>) { + if (instance_name && references_to_resolve) { + references_to_resolve->push_back(std::make_pair( + mutable_attribute_value{(uint32_t) *instance_name, resolve_reference_index == -1 ? (uint8_t) attribute_index : (uint8_t) resolve_reference_index}, + value + )); + } + } else if constexpr (std::is_same_v, std::vector>) { + if (instance_name && references_to_resolve) { + references_to_resolve->push_back({{(uint32_t) *instance_name, resolve_reference_index == -1 ? (uint8_t) attribute_index : (uint8_t) resolve_reference_index}, value}); + } + } else if constexpr (std::is_same_v, std::vector>>) { + if (instance_name && references_to_resolve) { + references_to_resolve->push_back({{(uint32_t) *instance_name, resolve_reference_index == -1 ? (uint8_t) attribute_index : (uint8_t) resolve_reference_index}, value}); + } + } else { + storage.set(attribute_index, value); + } +} + +template +void skip_aggregate(ifcopenshell::spf_lexer* tokens) { + size_t depth = 1; + while (depth) { + token next = tokens->next(); + if (!next) { + break; + } + if (next.is_operator('(')) { + ++depth; + } else if (next.is_operator(')')) { + --depth; + } + } +} + +template +direct_aggregate read_direct_aggregate( + ifcopenshell::impl::in_memory_file_storage& storage, + ifcopenshell::spf_lexer* tokens, + std::optional entity_instance_name, + const ifcopenshell::entity* entity, + int attribute_index, + const ifcopenshell::aggregation_type* aggregate_type +) { + direct_aggregate aggregate; + token next = tokens->next(); while (next) { if (next.is_operator(',')) { - if (attribute_index == -1) { - attribute_index_within_data += 1; - } } else if (next.is_operator(')')) { break; } else if (next.is_operator('(')) { - return_value++; - load(tokens, entity_instance_name, entity, context.push(), attribute_index == -1 ? (int) attribute_index_within_data : attribute_index); - } else { - return_value++; - if (next.is_identifier() && entity && entity_instance_name) { - register_inverse(*entity_instance_name, entity, next.value_int, attribute_index == -1 ? (int) attribute_index_within_data : attribute_index); + auto nested = read_direct_aggregate(storage, tokens, entity_instance_name, entity, attribute_index, nested_aggregation_type(aggregate_type)); + if (nested.values == 0 && nested.storage.index() == 0) { + aggregate.append_empty_nested(); + } else { + std::visit([&aggregate](const auto& value) { + if constexpr (!std::is_same_v, blank>) { + aggregate.append(value); + } + }, nested.storage); } + } else if (next.is_keyword()) { + try { + const auto* declaration = (storage.schema ? storage.schema : storage.file->schema())->declaration_by_name(next.as_string()); + tokens->next(); + auto data = storage.load(tokens, entity_instance_name, declaration, entity, attribute_index); + storage.read_simple_type_instances.push_back(data); + aggregate.append(ifcopenshell::reference_or_simple_type{express::Base(data)}); + } catch (exception& e) { + logger::message(logger::LOG_ERROR, std::string(e.what()) + " at offset " + std::to_string(next.start_pos)); + } + } else { + if (next.is_identifier() && entity && entity_instance_name) { + storage.register_inverse((unsigned)*entity_instance_name, entity, next.value_int, attribute_index); + } + dispatch_token_direct(next, aggregate_type && aggregate_type->type_of_element()->as_named_type() ? aggregate_type->type_of_element()->as_named_type()->declared_type() : nullptr, attribute_index, [&aggregate](const auto& value) { + aggregate.append(value); + }); + } + next = tokens->next(); + } - if (next.is_keyword()) { + if (aggregate.values == 0) { + append_empty_direct_aggregate(aggregate_type, aggregate); + } + + return aggregate; +} + +} // namespace + +// +// Reads the arguments from a list of tokens directly into instance_data storage. +// Additionally, registers the ids (i.e. #[\d]+) in the inverse map. +// +template +shared_pointer_type ifcopenshell::impl::in_memory_file_storage::load( + ifcopenshell::spf_lexer* tokens, + std::optional entity_instance_name, + const ifcopenshell::declaration* declaration, + const ifcopenshell::entity* entity, + int attribute_index, + bool coerce_attribute_count +) { + static_cast(coerce_attribute_count); + + parameter_type_view parameter_types(declaration); + const size_t expected_size = parameter_types.size(); + in_memory_attribute_storage storage(expected_size); + + token next = tokens->next(); + size_t attribute_index_within_data = 0; + size_t values_read = 0; + + while (next) { + if (next.is_operator(',')) { + ++attribute_index_within_data; + } else if (next.is_operator(')')) { + break; + } else { + ++values_read; + const bool retain_value = attribute_index_within_data < expected_size; + const ifcopenshell::parameter_type* parameter_type = retain_value ? parameter_types[attribute_index_within_data] : nullptr; + const int reference_attribute_index = attribute_index == -1 ? (int) attribute_index_within_data : attribute_index; + + if (next.is_operator('(')) { + if (retain_value) { + auto aggregate = read_direct_aggregate(*this, tokens, entity_instance_name, entity, reference_attribute_index, aggregate_parameter_type(parameter_type)); + std::visit([&](const auto& value) { + if constexpr (!std::is_same_v, blank>) { + set_direct_attribute(storage, entity_instance_name, references_to_resolve, attribute_index_within_data, attribute_index, value); + } + }, aggregate.storage); + } else { + skip_aggregate(tokens); + } + } else if (next.is_keyword()) { try { - const auto* decl = (schema ? schema : file->schema())->declaration_by_name(next.as_string()); - parse_context ps; + const auto* simple_declaration = (schema ? schema : file->schema())->declaration_by_name(next.as_string()); tokens->next(); - // The only case we know where a defined type contains entity - // instance references is IfcPropertySetDefinitionSet. For - // that purpose we propagate the entity_instance_name to - // register inverses to the host entity (and not the defined - // type) and to be able to actually register the references in - // the 2nd pass. - load(tokens, entity_instance_name, entity, ps, attribute_index == -1 ? (int)attribute_index_within_data : attribute_index); - express::Base simple_type_instance(read_simple_type_instances.emplace_back( - ps.construct(file, entity_instance_name, *references_to_resolve, decl, std::nullopt, attribute_index == -1 ? (int)attribute_index_within_data : attribute_index)) - ); - // @todo do we need express::Base here? Or should we just push instance_data? - context.push(simple_type_instance); + if (retain_value) { + auto data = load(tokens, entity_instance_name, simple_declaration, entity, reference_attribute_index); + read_simple_type_instances.push_back(data); + storage.set(attribute_index_within_data, express::Base(data)); + } else { + skip_aggregate(tokens); + } } catch (exception& e) { logger::message(logger::LOG_ERROR, std::string(e.what()) + " at offset " + std::to_string(next.start_pos)); - // #4070 We didn't actually capture an aggregate entry, undo length increment. - return_value--; + --values_read; } } else { - context.push(next); + if (next.is_identifier() && entity && entity_instance_name) { + register_inverse((unsigned)*entity_instance_name, entity, next.value_int, reference_attribute_index); + } + if (retain_value) { + dispatch_token_direct(next, declared_type(parameter_type), (int) attribute_index_within_data, [&](const auto& value) { + set_direct_attribute(storage, entity_instance_name, references_to_resolve, attribute_index_within_data, attribute_index, value); + }); + } } } next = tokens->next(); } + + warn_attribute_count(declaration, entity_instance_name, expected_size, values_read); + return ifcopenshell::make_pointer_type(file, declaration, (declaration && declaration->as_entity()) ? (uint32_t)entity_instance_name.value_or(0) : 0, std::move(storage)); } template @@ -640,17 +1011,13 @@ void ifcopenshell::impl::in_memory_file_storage::try_read_semicolon(ifcopenshell void ifcopenshell::impl::in_memory_file_storage::register_inverse(unsigned id_from, const ifcopenshell::entity* from_entity, int inst_id, int attribute_index) { // Assume a check on token type has already been performed - byref_excl_[inst_id][{from_entity->index_in_schema(), attribute_index}].push_back(id_from); + byref_excl_.add((uint32_t)inst_id, (uint32_t)id_from, (uint16_t)from_entity->index_in_schema(), attribute_index); } void ifcopenshell::impl::in_memory_file_storage::unregister_inverse(unsigned id_from, const ifcopenshell::entity* from_entity, const express::Base& inst, int attribute_index) { - auto& ids = byref_excl_[inst.id()][{from_entity->index_in_schema(), attribute_index}]; - auto iter = std::find(ids.begin(), ids.end(), id_from); - if (iter == ids.end()) { + if (!byref_excl_.remove((uint32_t)inst.id(), (uint32_t)id_from, (uint16_t)from_entity->index_in_schema(), attribute_index)) { // @todo inverses also need to be populated when multiple instances are added to a new file. // throw ifcopenshell::exception("Instance not found among inverses"); - } else { - ids.erase(iter); } } @@ -1382,17 +1749,16 @@ void read_terminal(spf_lexer& lexer, const std::string& term, bool trail } template -std::shared_ptr read_header_entity( +shared_pointer_type read_header_entity( ifcopenshell::file* file, ifcopenshell::impl::in_memory_file_storage& storage, spf_lexer& lexer, ifcopenshell::unresolved_references& references_to_resolve, const ifcopenshell::entity& decl) { - parse_context pc; lexer.next(); - storage.load(&lexer, std::nullopt, nullptr, pc, -1); - auto result = pc.construct(file, std::nullopt, references_to_resolve, &decl, decl.attribute_count(), -1); - return result; + storage.file = file; + storage.references_to_resolve = &references_to_resolve; + return storage.load(&lexer, std::nullopt, &decl, nullptr, -1); } template @@ -1480,6 +1846,20 @@ void ifcopenshell::instance_streamer::initialize_header() { } storage_.schema = schema_; + + types_to_bypass_materialized_.resize(schema_->declarations().size(), false); + for (auto& bp : types_to_bypass_) { + std::function mark; + mark = [&](const ifcopenshell::entity* e) { + types_to_bypass_materialized_[e->index_in_schema()] = true; + for (auto& subtype : e->subtypes()) { + mark(subtype); + } + }; + if (auto* e = bp->as_entity()) { + mark(e); + } + } } template @@ -1546,8 +1926,6 @@ ifcopenshell::instance_streamer::instance_streamer(ifcopenshell::file* f , schema_(nullptr) , progress_(0) { - init_locale(); - if constexpr (std::is_same_v>) { owned_stream_ = std::make_unique(caller_fed_tag{}); } else if constexpr (std::is_same_v>) { @@ -1570,10 +1948,9 @@ ifcopenshell::instance_streamer::instance_streamer(const std::string& fn , owner_(f) , token_stream_(3, token{}) , schema_(nullptr) - , progress_(0) { - init_locale(); - - if constexpr (std::is_same_v>) { + , progress_(0) +{ + if constexpr (std::is_same_v>) { (void)mmap; owned_stream_ = std::make_unique(fn); #ifdef USE_MMAP @@ -1600,8 +1977,6 @@ ifcopenshell::instance_streamer::instance_streamer(void* data, int lengt , schema_(nullptr) , progress_(0) { - init_locale(); - if constexpr (std::is_same_v>) { owned_stream_ = std::make_unique(std::string((char*)data, length), caller_fed_tag{}); } else if constexpr (std::is_same_v>) { @@ -1623,9 +1998,8 @@ ifcopenshell::instance_streamer::instance_streamer(Reader* stream, ifcop , owner_(f) , token_stream_(3, token{}) , schema_(nullptr) - , progress_(0) { - init_locale(); - + , progress_(0) +{ lexer_ = std::make_unique>(stream_); good_ = file_open_status::NO_HEADER; initialize_header(); @@ -1643,25 +2017,37 @@ void ifcopenshell::instance_streamer::bypass_types(const std::set -std::optional>> ifcopenshell::instance_streamer::read_instance() { - std::optional>> return_value; +std::optional> ifcopenshell::instance_streamer::read_instance() { + std::optional> return_value; if (yield_header_instances_ && header_ && yielded_header_instances_ < 3) { if (yielded_header_instances_ == 0) { return_value.emplace( 0, &header_->file_description().declaration(), +#ifdef IFOPSH_SAFE_INSTANCE header_->file_description().data_weak().lock()); +#else + header_->file_description().data_weak()); +#endif } else if (yielded_header_instances_ == 1) { return_value.emplace( 0, &header_->file_name().declaration(), +#ifdef IFOPSH_SAFE_INSTANCE header_->file_name().data_weak().lock()); +#else + header_->file_name().data_weak()); +#endif } else if (yielded_header_instances_ == 2) { return_value.emplace( 0, &header_->file_schema().declaration(), +#ifdef IFOPSH_SAFE_INSTANCE header_->file_schema().data_weak().lock()); +#else + header_->file_schema().data_weak()); +#endif } yielded_header_instances_ += 1; return return_value; @@ -1688,36 +2074,31 @@ std::optionalis(*ty)) { - bypassed_instances_.push_back(current_id); - current_id = 0; - goto advance; - } + if (types_to_bypass_materialized_[entity_type->index_in_schema()]) { + bypassed_instances_.push_back(current_id); + current_id = 0; + goto advance; } - parse_context ps; lexer_->next(); try { - storage_.load(lexer_.get(), current_id, entity_type->as_entity(), ps, -1); + auto data = storage_.load(lexer_.get(), current_id, entity_type, entity_type->as_entity(), -1, coerce_attribute_count); + + if (((++progress_) % 1000) == 0) { + std::stringstream ss; + ss << "\r#" << current_id; + logger::status(ss.str(), false); + } + + return_value.emplace( + (size_t)current_id, + entity_type, + data); } catch (const invalid_token_exception& e) { good_ = file_open_status::INVALID_SYNTAX; logger::error(e); break; } - - if (((++progress_) % 1000) == 0) { - std::stringstream ss; - ss << "\r#" << current_id; - logger::status(ss.str(), false); - } - - auto data = ps.construct(owner_, current_id, references_to_resolve_, entity_type, std::nullopt, -1, coerce_attribute_count); - - return_value.emplace( - (size_t)current_id, - entity_type, - data); } advance: token next_token; @@ -1747,8 +2128,6 @@ template class IFC_PARSE_API ifcopenshell::instance_streamer void ifcopenshell::impl::in_memory_file_storage::read_from_stream(Reader* s, const ifcopenshell::schema_definition*& schema, unsigned int& max_id, const std::set& typed_to_bypass) { - init_locale(); - schema = nullptr; if (!s->size() || s->eof()) { @@ -1831,7 +2210,8 @@ void ifcopenshell::impl::in_memory_file_storage::read_from_stream(Reader* s, con } good_ = streamer.status(); - byref_excl_ = streamer.inverses(); + byref_excl_ = std::move(streamer.inverses()); + byref_excl_.sort(); read_simple_type_instances = streamer.steal_instances(); logger::status("\rDone scanning file "); @@ -1861,7 +2241,11 @@ void ifcopenshell::impl::in_memory_file_storage::read_from_stream(Reader* s, con express::Base inst = storage->get_attribute_value(attr_index); if (!inst.declaration().as_entity()) { // Probably a case of IfcPropertySetDefinitionSet, divert storage of reference to the simply type instance +#ifdef IFOPSH_SAFE_INSTANCE storage = inst.data_weak().lock(); +#else + storage = inst.data_weak(); +#endif attr_index = 0; } } @@ -1901,7 +2285,11 @@ void ifcopenshell::impl::in_memory_file_storage::read_from_stream(Reader* s, con express::Base inst = storage->get_attribute_value(attr_index); if (!inst.declaration().as_entity()) { // Probably a case of IfcPropertySetDefinitionSet, divert storage of reference to the simply type instance +#ifdef IFOPSH_SAFE_INSTANCE storage = inst.data_weak().lock(); +#else + storage = inst.data_weak(); +#endif attr_index = 0; } } @@ -1939,7 +2327,11 @@ void ifcopenshell::impl::in_memory_file_storage::read_from_stream(Reader* s, con express::Base inst = storage->get_attribute_value(attr_index); if (!inst.declaration().as_entity()) { // Probably a case of IfcPropertySetDefinitionSet, divert storage of reference to the simply type instance +#ifdef IFOPSH_SAFE_INSTANCE storage = inst.data_weak().lock(); +#else + storage = inst.data_weak(); +#endif attr_index = 0; } } @@ -2362,28 +2754,7 @@ void ifcopenshell::impl::in_memory_file_storage::process_deletion_inverse(const // Delete inverses into entity byref_excl_.erase(id); - - // This is based on traversal which needs instances to still be contained in the map. - // another option would be to keep byid intact for the remainder of this loop - auto entity_attributes = traverse(entity, 1); - for (auto it = entity_attributes.begin(); it != entity_attributes.end(); ++it) { - auto entity_attribute = *it; - if (entity_attribute == entity) { - continue; - } - const unsigned int name = entity_attribute.id(); - // Do not update inverses for simple types (which have id()==0 in IfcOpenShell). - if (name != 0) { - // Find instances entity -> other - // and update inverses from entity into other - auto submap = byref_excl_.find(name); - if (submap != byref_excl_.end()) { - for (auto& [key, ids] : submap->second) { - ids.erase(std::remove(ids.begin(), ids.end(), id), ids.end()); - } - } - } - } + byref_excl_.remove_source(id); } namespace { @@ -2453,14 +2824,10 @@ std::vector file::instances_by_reference(int t) { std::vector ret; std::visit([this, t, &ret](auto& x) { if constexpr (std::is_same_v, impl::in_memory_file_storage>) { - auto submap = x.byref_excl_.find(t); - if (submap == x.byref_excl_.end()) { - return; - } - for (auto& [key, ids] : submap->second) { - for (auto& i : ids) { - ret.push_back(instance_by_id(i)); - } + auto range = x.byref_excl_.equal_range((uint32_t)t); + ret.reserve(ret.size() + (size_t)std::distance(range.first, range.second)); + for (auto it = range.first; it != range.second; ++it) { + ret.push_back(instance_by_id(it->source_id)); } } #ifdef IFOPSH_WITH_ROCKSDB @@ -2611,18 +2978,16 @@ std::vector file::get_inverse_indices_by_id(int instance_id) { // Mapping of instance id to attribute offset. std::map> mapping; + bool handled = false; - std::visit([&mapping, instance_id](const auto& x) { + std::visit([&mapping, &return_value, &handled, instance_id](auto& x) { if constexpr (std::is_same_v, std::monostate>) { } else if constexpr (std::is_same_v, impl::in_memory_file_storage>) { - auto submap = x.byref_excl_.find(instance_id); - if (submap == x.byref_excl_.end()) { - return; - } - for (auto& [key, ids] : submap->second) { - for (auto& i : ids) { - mapping[i].push_back(std::get<1>(key)); - } + handled = true; + auto range = x.byref_excl_.equal_range((uint32_t)instance_id); + return_value.reserve((size_t)std::distance(range.first, range.second)); + for (auto it = range.first; it != range.second; ++it) { + return_value.push_back(it->attribute_index); } } else if constexpr (std::is_same_v, impl::rocks_db_file_storage>) { #ifdef IFOPSH_WITH_ROCKSDB @@ -2643,6 +3008,10 @@ std::vector file::get_inverse_indices_by_id(int instance_id) { } }, storage_); + if (handled) { + return return_value; + } + auto refs = instances_by_reference(instance_id); for (const auto& ref : refs) { @@ -2677,29 +3046,19 @@ std::vector file::get_inverse(int instance_id, const ifcopenshe return return_value; } - std::visit([&return_value, this, attribute_index, instance_id, type](const auto& x) { + std::visit([&return_value, this, attribute_index, instance_id, type](auto& x) { if constexpr (std::is_same_v, std::monostate>) { } else if constexpr (std::is_same_v, impl::in_memory_file_storage>) { - auto submap = x.byref_excl_.find(instance_id); - if (submap != x.byref_excl_.end()) { - visit_subtypes(type->as_entity(), [this, attribute_index, instance_id, &return_value, &submap](const ifcopenshell::declaration* ent) { - if (attribute_index == -1) { - auto lower = submap->second.lower_bound({ent->index_in_schema(), std::numeric_limits::min()}); - auto upper = submap->second.upper_bound({ent->index_in_schema(), std::numeric_limits::max()}); - for (auto it = lower; it != upper; ++it) { - for (auto& i : it->second) { - return_value.push_back(instance_by_id(i).template as()); - } - } - } else { - auto it = submap->second.find({ent->index_in_schema(), attribute_index}); - if (it != submap->second.end()) { - for (auto& i : it->second) { - return_value.push_back(instance_by_id(i).template as()); - } - } - } - }); + std::vector source_types(schema()->declarations().size(), 0); + visit_subtypes(type->as_entity(), [&source_types](const ifcopenshell::declaration* ent) { + source_types[ent->index_in_schema()] = 1; + }); + auto range = x.byref_excl_.equal_range((uint32_t)instance_id); + for (auto it = range.first; it != range.second; ++it) { + if (it->source_entity < source_types.size() && source_types[it->source_entity] && + (attribute_index == -1 || it->attribute_index == attribute_index)) { + return_value.push_back(instance_by_id(it->source_id).template as()); + } } } #ifdef IFOPSH_WITH_ROCKSDB @@ -2737,17 +3096,12 @@ std::vector file::get_inverse(int instance_id, const ifcopenshe size_t file::get_total_inverses(int instance_id) { std::set counted_ids; - std::visit([&counted_ids, instance_id](const auto& x) { + std::visit([&counted_ids, instance_id](auto& x) { if constexpr (std::is_same_v, std::monostate>) { } else if constexpr (std::is_same_v, impl::in_memory_file_storage>) { - auto submap = x.byref_excl_.find(instance_id); - if (submap == x.byref_excl_.end()) { - return; - } - for (auto& [key, ids] : submap->second) { - for (auto& i : ids) { - counted_ids.insert(i); - } + auto range = x.byref_excl_.equal_range((uint32_t)instance_id); + for (auto it = range.first; it != range.second; ++it) { + counted_ids.insert(it->source_id); } } else if constexpr (std::is_same_v, impl::rocks_db_file_storage>) { // @todo @@ -2846,7 +3200,7 @@ void ifcopenshell::file::build_inverses_(const express::Base& inst) { std::visit([entity_attribute_id, decl, idx, inst](auto& x) { if constexpr (std::is_same_v, std::monostate>) { } else if constexpr (std::is_same_v, impl::in_memory_file_storage>) { - x.byref_excl_[entity_attribute_id][{decl->index_in_schema(), idx}].push_back(inst.id()); + x.byref_excl_.add(entity_attribute_id, inst.id(), (uint16_t)decl->index_in_schema(), idx); } else if constexpr (std::is_same_v, impl::rocks_db_file_storage>) { // @todo } diff --git a/src/ifcparse/rocksdb_map_adapter.h b/src/ifcparse/rocksdb_map_adapter.h index 7d0e5a8f63..2ea85bd215 100644 --- a/src/ifcparse/rocksdb_map_adapter.h +++ b/src/ifcparse/rocksdb_map_adapter.h @@ -30,6 +30,8 @@ #include #include #include +#include +#include template struct is_std_tuple : std::false_type {}; diff --git a/src/ifcparse/schema.h b/src/ifcparse/schema.h index ff9abedf58..0ce09181d2 100644 --- a/src/ifcparse/schema.h +++ b/src/ifcparse/schema.h @@ -26,9 +26,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include diff --git a/src/ifcparse/spf_header.cpp b/src/ifcparse/spf_header.cpp index d3ccff3c4d..4f9b2e0b1c 100644 --- a/src/ifcparse/spf_header.cpp +++ b/src/ifcparse/spf_header.cpp @@ -11,16 +11,16 @@ using namespace ifcopenshell; namespace { -std::shared_ptr make_header_entity(ifcopenshell::file* file, const ifcopenshell::entity& decl) { +shared_pointer_type make_header_entity(ifcopenshell::file* file, const ifcopenshell::entity& decl) { const bool in_memory = file == nullptr || std::visit([](auto& storage) { return std::is_same_v, ifcopenshell::impl::in_memory_file_storage>; }, file->storage_); if (in_memory) { - return std::make_shared(file, &decl, 0, in_memory_attribute_storage(decl.attribute_count())); + return ifcopenshell::make_pointer_type(file, &decl, 0, in_memory_attribute_storage(decl.attribute_count())); } - return std::make_shared(file, &decl, 0, rocks_db_attribute_storage{}); + return ifcopenshell::make_pointer_type(file, &decl, 0, rocks_db_attribute_storage{}); } } // namespace @@ -60,15 +60,15 @@ void ifcopenshell::spf_header::owner_file(ifcopenshell::file* file) { file_ = file; } -void ifcopenshell::spf_header::set_file_description(const std::shared_ptr& data) { +void ifcopenshell::spf_header::set_file_description(const shared_pointer_type& data) { header_entities_[0] = data; } -void ifcopenshell::spf_header::set_file_name(const std::shared_ptr& data) { +void ifcopenshell::spf_header::set_file_name(const shared_pointer_type& data) { header_entities_[1] = data; } -void ifcopenshell::spf_header::set_file_schema(const std::shared_ptr& data) { +void ifcopenshell::spf_header::set_file_schema(const shared_pointer_type& data) { header_entities_[2] = data; } diff --git a/src/ifcparse/spf_header.h b/src/ifcparse/spf_header.h index efd6304553..4c73deff68 100644 --- a/src/ifcparse/spf_header.h +++ b/src/ifcparse/spf_header.h @@ -32,7 +32,7 @@ class IFC_PARSE_API spf_header { private: ifcopenshell::file* file_; - std::array, 3> header_entities_; + std::array header_entities_; public: explicit spf_header(ifcopenshell::file* owner_file); @@ -43,9 +43,9 @@ class IFC_PARSE_API spf_header { ifcopenshell::file* owner_file() { return file_; } void owner_file(ifcopenshell::file* file); - void set_file_description(const std::shared_ptr& description_data); - void set_file_name(const std::shared_ptr& name_data); - void set_file_schema(const std::shared_ptr& schema_data); + void set_file_description(const shared_pointer_type& description_data); + void set_file_name(const shared_pointer_type& name_data); + void set_file_schema(const shared_pointer_type& schema_data); const Header_section_schema::file_description file_description() const; const Header_section_schema::file_name file_name() const; diff --git a/src/ifcparse/storage.h b/src/ifcparse/storage.h index 50cf35a890..68d01406c2 100644 --- a/src/ifcparse/storage.h +++ b/src/ifcparse/storage.h @@ -28,7 +28,12 @@ namespace rocksdb { #include #include +#include +#include #include +#include +#include +#include #include #include #include @@ -37,6 +42,7 @@ namespace rocksdb { #include #include #include +#include #ifndef SWIG @@ -131,7 +137,7 @@ namespace ifcopenshell { }; typedef std::variant reference_or_simple_type; - typedef std::list, std::vector>>>> unresolved_references; + typedef std::vector, std::vector>>>> unresolved_references; class file; template @@ -205,38 +211,206 @@ namespace ifcopenshell { } }; - struct IFC_PARSE_API parse_context { - std::vector< - std::variant< - express::Base, - token, - parse_context* - >> tokens_; - - parse_context() {} - ~parse_context(); - - parse_context(const parse_context& other) = delete; - parse_context& operator=(const parse_context& other) = delete; - - parse_context(parse_context&& other) = default; - parse_context& operator=(parse_context&& other) = default; - - parse_context& push(); - - void push(token next_token); - - void push(const express::Base& instance); - - std::shared_ptr construct(ifcopenshell::file* owner_file, std::optional instance_name, unresolved_references& references_to_resolve, const ifcopenshell::declaration* declaration, std::optional expected_size, int resolve_reference_index, bool coerce_attribute_count = true); - }; - namespace impl { + struct inverse_record { + uint32_t referenced_id; + uint32_t source_id; + uint16_t source_entity; + int16_t attribute_index; + }; + + class inverse_index { + public: + typedef std::map, std::vector> legacy_bucket_t; + typedef std::unordered_map legacy_map_t; + typedef legacy_map_t::key_type key_type; + typedef legacy_map_t::mapped_type mapped_type; + typedef legacy_map_t::value_type value_type; + typedef legacy_map_t::iterator iterator; + typedef legacy_map_t::const_iterator const_iterator; + typedef std::vector::const_iterator record_iterator; + + private: + mutable std::vector records_; + mutable bool sorted_ = true; + mutable std::unique_ptr materialized_; + + static bool record_less(const inverse_record& a, const inverse_record& b) { + if (a.referenced_id != b.referenced_id) { + return a.referenced_id < b.referenced_id; + } + if (a.source_entity != b.source_entity) { + return a.source_entity < b.source_entity; + } + if (a.attribute_index != b.attribute_index) { + return a.attribute_index < b.attribute_index; + } + return a.source_id < b.source_id; + } + + static bool referenced_less(const inverse_record& a, uint32_t referenced_id) { + return a.referenced_id < referenced_id; + } + + static bool referenced_less(uint32_t referenced_id, const inverse_record& a) { + return referenced_id < a.referenced_id; + } + + void invalidate_materialized() const { + materialized_.reset(); + } + + legacy_map_t& materialize() const { + if (!materialized_) { + materialized_ = std::make_unique(); + materialized_->reserve(records_.size()); + for (const auto& record : records_) { + (*materialized_)[(int)record.referenced_id][{(short)record.source_entity, (short)record.attribute_index}].push_back(record.source_id); + } + } + return *materialized_; + } + + public: + inverse_index() = default; + + inverse_index(const inverse_index& other) + : records_(other.records_) + , sorted_(other.sorted_) + {} + + inverse_index& operator=(const inverse_index& other) { + if (this != &other) { + records_ = other.records_; + sorted_ = other.sorted_; + materialized_.reset(); + } + return *this; + } + + inverse_index(inverse_index&&) noexcept = default; + inverse_index& operator=(inverse_index&&) noexcept = default; + + void reserve(size_t size) { + records_.reserve(size); + } + + void add(uint32_t referenced_id, uint32_t source_id, uint16_t source_entity, int attribute_index) { + records_.push_back({referenced_id, source_id, source_entity, (int16_t)attribute_index}); + sorted_ = false; + invalidate_materialized(); + } + + bool remove(uint32_t referenced_id, uint32_t source_id, uint16_t source_entity, int attribute_index) { + const inverse_record needle{referenced_id, source_id, source_entity, (int16_t)attribute_index}; + auto it = std::find_if(records_.begin(), records_.end(), [&needle](const inverse_record& record) { + return record.referenced_id == needle.referenced_id && + record.source_id == needle.source_id && + record.source_entity == needle.source_entity && + record.attribute_index == needle.attribute_index; + }); + if (it == records_.end()) { + return false; + } + records_.erase(it); + invalidate_materialized(); + return true; + } + + void remove_source(uint32_t source_id) { + records_.erase(std::remove_if(records_.begin(), records_.end(), [source_id](const inverse_record& record) { + return record.source_id == source_id; + }), records_.end()); + invalidate_materialized(); + } + + void sort() const { + if (!sorted_) { + std::sort(records_.begin(), records_.end(), record_less); + sorted_ = true; + invalidate_materialized(); + } + } + + std::pair equal_range(uint32_t referenced_id) const { + sort(); + return std::equal_range(records_.begin(), records_.end(), referenced_id, [](const auto& a, const auto& b) { + if constexpr (std::is_same_v, inverse_record>) { + return referenced_less(a, b); + } else { + return referenced_less(a, b); + } + }); + } + + const std::vector& records() const { + sort(); + return records_; + } + + bool empty() const { + return records_.empty(); + } + + size_t size() const { + return records_.size(); + } + + void clear() { + records_.clear(); + sorted_ = true; + materialized_.reset(); + } + + iterator begin() { + return materialize().begin(); + } + + iterator end() { + return materialize().end(); + } + + const_iterator begin() const { + return materialize().begin(); + } + + const_iterator end() const { + return materialize().end(); + } + + iterator find(const key_type& key) { + return materialize().find(key); + } + + const_iterator find(const key_type& key) const { + return materialize().find(key); + } + + size_t erase(const key_type& key) { + const auto old_size = records_.size(); + records_.erase(std::remove_if(records_.begin(), records_.end(), [key](const inverse_record& record) { + return record.referenced_id == (uint32_t)key; + }), records_.end()); + invalidate_materialized(); + return old_size - records_.size(); + } + + std::pair insert(const value_type& value) { + for (const auto& bucket : value.second) { + for (auto source_id : bucket.second) { + add((uint32_t)value.first, source_id, (uint16_t)std::get<0>(bucket.first), std::get<1>(bucket.first)); + } + } + auto it = find(value.first); + return {it, true}; + } + }; + struct IFC_PARSE_API in_memory_file_storage { - std::vector> read_simple_type_instances; - std::vector> steal_instances() { - return read_simple_type_instances; + std::vector read_simple_type_instances; + std::vector steal_instances() { + return std::move(read_simple_type_instances); } // Either one of these needs to be set @@ -246,14 +420,14 @@ namespace ifcopenshell { unresolved_references* references_to_resolve = nullptr; typedef std::map> entities_by_type_t; - typedef boost::unordered_map> entity_instance_by_name_storage_t; - typedef map_transformer)>> entity_instance_by_name_t; - typedef boost::unordered_map> type_instance_by_name_t; + typedef boost::unordered_map entity_instance_by_name_storage_t; + typedef map_transformer> entity_instance_by_name_t; + typedef boost::unordered_map type_instance_by_name_t; typedef std::map entity_instance_by_guid_t; - typedef std::unordered_map, std::vector>> entities_by_ref_t; + typedef inverse_index entities_by_ref_t; typedef entity_instance_by_name_t::iterator iterator; - in_memory_file_storage(ifcopenshell::file* owner_file = nullptr) : file(owner_file), schema(nullptr), byid_read_(&byid_, [this](const std::shared_ptr& data) { return express::Base(data); }) {}; + in_memory_file_storage(ifcopenshell::file* owner_file = nullptr) : file(owner_file), schema(nullptr), byid_read_(&byid_, [this](const shared_pointer_type& data) { return express::Base(data); }) {}; in_memory_file_storage(const in_memory_file_storage& other) = delete; in_memory_file_storage(const in_memory_file_storage&& other) = delete; @@ -299,7 +473,7 @@ namespace ifcopenshell { entity_instance_by_name_t byid_read_; template - void load(ifcopenshell::spf_lexer* tokens, std::optional entity_instance_name, const ifcopenshell::entity* entity, parse_context& context, int attribute_index = -1); + shared_pointer_type load(ifcopenshell::spf_lexer* tokens, std::optional entity_instance_name, const ifcopenshell::declaration* declaration, const ifcopenshell::entity* entity, int attribute_index = -1, bool coerce_attribute_count = true); template void try_read_semicolon(ifcopenshell::spf_lexer* tokens) const; @@ -340,7 +514,7 @@ namespace ifcopenshell { class IFC_PARSE_API rocks_db_file_storage { public: - rocksdb::DB* db; + std::unique_ptr db; rocksdb::WriteOptions wopts; rocksdb::ReadOptions ropts; ifcopenshell::file* file; @@ -353,7 +527,7 @@ namespace ifcopenshell { // to make sure that instance pointer are constant during file lifetime // cache instances because we want stable pointers // @todo this is silly, but we cannot have the same type, this should be just a pointer then on the file side? - typedef std::map> entity_by_iden_cache_t; + typedef std::map entity_by_iden_cache_t; entity_by_iden_cache_t instance_cache_, type_instance_cache_; std::mutex instance_cache_mutex_; diff --git a/src/ifcparse/variant_array.h b/src/ifcparse/variant_array.h index 69c4adee45..d14f31148a 100644 --- a/src/ifcparse/variant_array.h +++ b/src/ifcparse/variant_array.h @@ -33,6 +33,10 @@ variant - which is the maximum size of its constituents - is reduced. #include #include #include +#include +#include +#include +#include #include "exception.h" diff --git a/src/ifcpatch/ifcpatch/recipes/ExtractElements.py b/src/ifcpatch/ifcpatch/recipes/ExtractElements.py index 6f88f820be..10d8b23330 100644 --- a/src/ifcpatch/ifcpatch/recipes/ExtractElements.py +++ b/src/ifcpatch/ifcpatch/recipes/ExtractElements.py @@ -33,7 +33,7 @@ class Patcher(ifcpatch.BasePatcher): file: ifcopenshell.file, logger: Union[Logger, None] = None, query: str = "IfcWall", - assume_asset_uniqueness_by_name: bool = True, + assume_asset_uniqueness_by_name: bool = False, ): """Extract certain elements into a new model diff --git a/src/ifcquery/README.md b/src/ifcquery/README.md index 5cb895dc44..b5c27e5f31 100644 --- a/src/ifcquery/README.md +++ b/src/ifcquery/README.md @@ -474,11 +474,11 @@ ifcedit run model.ifc spatial.unassign_container \ --products "$(ifcquery model.ifc --format ids select 'IfcWall')" # Delete every window (model opened and saved once) -ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id} +ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product '{id}' # Bulk rename all doors ifcquery model.ifc select 'IfcDoor' | ifcedit foreach model.ifc attribute.edit_attributes \ - --product {id} --attributes '{"Name": "Door"}' + --product '{id}' --attributes '{"Name": "Door"}' # Render an element highlighted against everything related to it ifcquery model.ifc render relations.png \ diff --git a/src/plugin/plugin.cpp b/src/plugin/plugin.cpp index 1e0e59844a..9a967fa4b8 100644 --- a/src/plugin/plugin.cpp +++ b/src/plugin/plugin.cpp @@ -54,10 +54,14 @@ namespace { } void plugin_debug(const std::string& message) { +#ifdef IFOPSH_PLUGIN_DEBUG #if defined(_MSC_VER) && defined(_UNICODE) std::wcerr << "[ifcopenshell.plugin] " << message.c_str() << std::endl; #else std::cerr << "[ifcopenshell.plugin] " << message << std::endl; +#endif +#else + static_cast(message); #endif } diff --git a/src/pyodide/demo-app/index.html b/src/pyodide/demo-app/index.html index 53d5462f31..b689287e15 100644 --- a/src/pyodide/demo-app/index.html +++ b/src/pyodide/demo-app/index.html @@ -78,14 +78,11 @@ await micropip.install("typing-extensions"); document.querySelector("#status2").innerHTML = "Loading IfcOpenShell"; - // await micropip.install("wheels/ifcopenshell-0.8.6-cp313-cp313-emscripten_4_0_9_wasm32.whl"); - - await micropip.install("wheels/modular/0.8.6-b1899b1/ifcopenshell-0.8.6+b1899b1-cp313-cp313-pyodide_2025_0_wasm32.whl"); - await micropip.install("wheels/modular/0.8.6-b1899b1/ifcopenshell_parse_schema_ifc4-0.8.6+b1899b1-cp313-cp313-pyodide_2025_0_wasm32.whl"); - await micropip.install("wheels/modular/0.8.6-b1899b1/ifcopenshell_geometry_mapping_ifc4-0.8.6+b1899b1-cp313-cp313-pyodide_2025_0_wasm32.whl"); - await micropip.install("wheels/modular/0.8.6-b1899b1/ifcopenshell_pure_python-0.8.6+b1899b1-py3-none-any.whl"); - await micropip.install("wheels/modular/0.8.6-b1899b1/ifcopenshell_geometry_kernel_cgalsimple-0.8.6+b1899b1-cp313-cp313-pyodide_2025_0_wasm32.whl"); - await micropip.install("wheels/modular/0.8.6-b1899b1/ifcopenshell_geometry_kernel_opencascade-0.8.6+b1899b1-cp313-cp313-pyodide_2025_0_wasm32.whl"); + await micropip.install("wheels/modular/0.8.6+424e70a/ifcopenshell-0.8.6+424e70a-cp313-cp313-pyodide_2025_0_wasm32.whl"); + await micropip.install("wheels/modular/0.8.6+424e70a/ifcopenshell_parse_schema_ifc4-0.8.6+424e70a-cp313-cp313-pyodide_2025_0_wasm32.whl"); + await micropip.install("wheels/modular/0.8.6+424e70a/ifcopenshell_geometry_mapping_ifc4-0.8.6+424e70a-cp313-cp313-pyodide_2025_0_wasm32.whl"); + await micropip.install("wheels/modular/0.8.6+424e70a/ifcopenshell_pure_python-0.8.6+424e70a-py3-none-any.whl"); + await micropip.install("wheels/modular/0.8.6+424e70a/ifcopenshell_geometry_kernel_manifold-0.8.6+424e70a-cp313-cp313-pyodide_2025_0_wasm32.whl"); document.body.className = ''; @@ -295,7 +292,7 @@ 'settings': s, 'file_or_filename': ifc, 'exclude': ['IfcSpace', 'IfcOpeningElement'], - 'geometry_library': 'hybrid-cgal-simple-opencascade' + 'geometry_library': 'manifold' }); let last_mesh_id = null; @@ -372,7 +369,7 @@ addObjToScene(ifcopenshell_geom.create_shape.callKwargs({ 'settings': s, 'inst': el, - 'geometry_library': 'hybrid-cgal-simple-opencascade' + 'geometry_library': 'manifold' })); } diff --git a/src/serializers/CMakeLists.txt b/src/serializers/CMakeLists.txt index b7f9a7675e..dc29ea7fbc 100644 --- a/src/serializers/CMakeLists.txt +++ b/src/serializers/CMakeLists.txt @@ -56,7 +56,7 @@ function(add_geometry_serializer_plugin target output_name) endfunction() if(WITH_ROCKSDB) - add_document_serializer_plugin(document_serializer_rdb "document.rdb" SOURCES document_rdb_plugin.cpp RocksDbSerializer.cpp LIBRARIES RocksDB::rocksdb) + add_document_serializer_plugin(document_serializer_rdb "document.rdb" SOURCES document_rdb_plugin.cpp RocksDbSerializer.cpp LIBRARIES ${IFCOPENSHELL_ROCKSDB_TARGET}) endif() add_subdirectory(schema_dependent) diff --git a/src/serializers/GltfSerializer.cpp b/src/serializers/GltfSerializer.cpp index 83b0ddedbe..2948798fec 100644 --- a/src/serializers/GltfSerializer.cpp +++ b/src/serializers/GltfSerializer.cpp @@ -23,6 +23,8 @@ #include "../ifcparse/utils.h" +#include + #ifdef WITH_PROJ #include #endif diff --git a/src/serializers/RocksDbSerializer.cpp b/src/serializers/RocksDbSerializer.cpp index c7247938f1..774e08045c 100644 --- a/src/serializers/RocksDbSerializer.cpp +++ b/src/serializers/RocksDbSerializer.cpp @@ -4,6 +4,9 @@ #include +#include +#include + #include "../ifcparse/logger.h" RocksDbSerializer::RocksDbSerializer(const std::string& input_filename, const std::string& rocksdb_filename, const std::vector& skip_supertypes) diff --git a/src/svgfill/src/arrange_polygons.cpp b/src/svgfill/src/arrange_polygons.cpp index 4ffaa29068..4c5a25652c 100644 --- a/src/svgfill/src/arrange_polygons.cpp +++ b/src/svgfill/src/arrange_polygons.cpp @@ -267,11 +267,12 @@ void clean_polygon(Polygon_2& poly) { void smooth_polygon(double factor, Polygon_2& poly) { auto ps = create_and_convert_offset_polygon(-factor, poly); - if (ps.size() == 1) { - auto r2 = ps.front(); - ps = create_and_convert_offset_polygon(+factor, r2); - if (ps.size() == 1) { - poly = ps.front(); + auto it = std::max_element(ps.begin(), ps.end(), [&](const auto& p, const auto& q) { return p.area() < q.area(); }); + if (it != ps.end()) { + auto qs = create_and_convert_offset_polygon(+factor, *it); + auto jt = std::max_element(qs.begin(), qs.end(), [&](const auto& p, const auto& q) { return p.area() < q.area(); }); + if (jt != qs.end()) { + poly = *jt; } } } @@ -432,6 +433,15 @@ class DebugWriter { } } + void write_point(const Point_2& p, const std::string& name) { + if (enabled_) { + obj << "o " << name << "\n"; + obj << "v " << CGAL::to_double(p.x()) << " " << CGAL::to_double(p.y()) << " 0\n"; + vi++; + svg << "\n"; + } + } + void write_polygon(const Polygon_with_holes_2& polygon, const std::string& name) { if (enabled_) { write_polygon(polygon.outer_boundary(), name); @@ -452,6 +462,20 @@ class DebugWriter { } } + void write_polygons(const Arrangement_2& arr, const std::string& name) { + if (enabled_) { + // Just for the automatic numbering, create a full vector + std::vector temp; + for (auto it = arr.faces_begin(); it != arr.faces_end(); ++it) { + if (it->is_unbounded()) { + continue; + } + temp.push_back(circ_to_poly(it->outer_ccb())); + } + write_polygons(temp, name); + } + } + void write_polygons(const std::vector& polygons, const std::string& name) { if (enabled_) { size_t i = 0; @@ -475,7 +499,14 @@ class DebugWriter { std::string last_segment_name_; void write_polygon_to_svg_(std::ostream& ofs, const Polygon_2& polygon, const std::string& class_name = "") { - ofs << "x()) << "," << -CGAL::to_double(vit->y()) << " "; } @@ -804,6 +835,10 @@ class SegmentLookup { return out; } + PolygonIt end() const { + return polygons_ref_.end(); + } + private: using TreeTraits = CGAL::AABB_traits>::iterator>>; using Tree = CGAL::AABB_tree; @@ -816,25 +851,33 @@ private: std::map::const_iterator> input_polygon_boundary_cache_; }; -Polygon_2 subdivide_polygon(double max_distance, const Polygon_2 & p) { +Polygon_2 subdivide_polygon_on_same_input(SegmentLookup& segment_lookup, double max_distance, const Polygon_2& p, std::map& point_lookup) { std::vector points; for (auto it = p.edges_begin(); it != p.edges_end(); ++it) { + auto source_poly = segment_lookup.input_polygon_boundary(it->source()); + auto target_poly = segment_lookup.input_polygon_boundary(it->target()); const auto& seg = *it; - auto num_splits = (int)std::ceil(std::sqrt(CGAL::to_double(seg.squared_length())) / max_distance) - 1; points.push_back(seg.source()); - for (auto i = 0; i < num_splits; ++i) { - auto d = (seg.target() - seg.source()) / (num_splits + 1) * (i + 1); - points.push_back(seg.source() + d); + if (source_poly == target_poly && source_poly != segment_lookup.end()) { + point_lookup.emplace(seg.source(), source_poly); + point_lookup.emplace(seg.target(), source_poly); + auto num_splits = (int)std::ceil(std::sqrt(CGAL::to_double(seg.squared_length())) / max_distance) - 1; + for (auto i = 0; i < num_splits; ++i) { + auto d = (seg.target() - seg.source()) / (num_splits + 1) * (i + 1); + auto p = seg.source() + d; + point_lookup.emplace(p, source_poly); + points.push_back(p); + } } } return Polygon_2(points.begin(), points.end()); }; -Polygon_with_holes_2 subdivide_polygon(double max_distance, const Polygon_with_holes_2& pwh) { - Polygon_2 outer = subdivide_polygon(max_distance, pwh.outer_boundary()); +Polygon_with_holes_2 subdivide_polygon_on_same_input(SegmentLookup& segment_lookup, double max_distance, const Polygon_with_holes_2& pwh, std::map& point_lookup) { + Polygon_2 outer = subdivide_polygon_on_same_input(segment_lookup, max_distance, pwh.outer_boundary(), point_lookup); std::vector holes; for (auto hit = pwh.holes_begin(); hit != pwh.holes_end(); ++hit) { - holes.push_back(subdivide_polygon(max_distance, *hit)); + holes.push_back(subdivide_polygon_on_same_input(segment_lookup, max_distance, *hit, point_lookup)); } return Polygon_with_holes_2(outer, holes.begin(), holes.end()); }; @@ -842,10 +885,9 @@ Polygon_with_holes_2 subdivide_polygon(double max_distance, const Polygon_with_h std::tuple< std::map>, std::map>, - std::map, std::vector*>>, - std::map + std::map, std::vector*>> > -build_line_graph(const std::vector& input_polygons, SegmentLookup& segment_lookup, const std::vector& triangular_polygons) +build_line_graph(const std::vector& input_polygons, const std::map& point_lookup, const std::vector& triangular_polygons) { // Build maps of triangle -> edge and edge -> triangle in order to do traversal on the 'corridor mesh' @@ -854,7 +896,9 @@ build_line_graph(const std::vector& input_polygons, SegmentLookup& se std::map, Point_2> segment_to_midpoint; std::map> midpoint_to_segment; std::map*, std::vector>> facet_to_segment; - std::map midpoint_to_edge_length; + + + // std::map midpoint_to_edge_length; for (auto& tri : triangular_polygons) { for (size_t i = 0; i < 3; ++i) { @@ -874,16 +918,20 @@ build_line_graph(const std::vector& input_polygons, SegmentLookup& se for (auto& p : segment_to_facet) { auto center = CGAL::ORIGIN + (((p.first.first - CGAL::ORIGIN) + (p.first.second - CGAL::ORIGIN)) / 2); - auto p1index = segment_lookup.input_polygon_boundary(p.first.first); - auto p2index = segment_lookup.input_polygon_boundary(p.first.second); + auto p1index = point_lookup.find(p.first.first); + auto p2index = point_lookup.find(p.first.second); - segment_to_input_facet[p.first].push_back(&*p1index); - segment_to_input_facet[p.first].push_back(&*p2index); + if (p1index == point_lookup.end() || p2index == point_lookup.end()) { + continue; + } - if (p1index != input_polygons.end() && p2index != input_polygons.end() && p1index != p2index) { + segment_to_input_facet[p.first].push_back(&*p1index->second); + segment_to_input_facet[p.first].push_back(&*p2index->second); + + if (p1index->second != input_polygons.end() && p2index->second != input_polygons.end() && p1index->second != p2index->second) { segment_to_midpoint[p.first] = center; midpoint_to_segment[center] = p.first; - midpoint_to_edge_length[center] = std::sqrt(CGAL::to_double(CGAL::squared_distance(p.first.first, p.first.second))); + // midpoint_to_edge_length[center] = std::sqrt(CGAL::to_double(CGAL::squared_distance(p.first.first, p.first.second))); } } @@ -903,7 +951,7 @@ build_line_graph(const std::vector& input_polygons, SegmentLookup& se } } - return {line_graph, midpoint_to_segment, segment_to_input_facet, midpoint_to_edge_length}; + return {line_graph, midpoint_to_segment, segment_to_input_facet}; // } , midpoint_to_edge_length}; } using DPoint = CGAL::Simple_cartesian::Point_2; @@ -912,8 +960,8 @@ using DBox = std::array; struct CenterLineGraphData { std::vector points; + std::vector>> orig_segments; std::vector points_double; - std::vector widths; std::vector> edges; std::vector> incident_edges; }; @@ -1039,9 +1087,48 @@ bool aabb_overlap(const DBox& a, const DBox& b, double eps = 1.e-9) { a[1].y() + eps >= b[0].y(); } +std::pair projected_interval_on_axis(const std::array& points, const DDir& axis_u) { + auto u = unit(axis_u); + auto t0 = (points.front() - CGAL::ORIGIN) * u; + auto interval = std::make_pair(t0, t0); + for (auto& p : points) { + auto t = (p - CGAL::ORIGIN) * u; + interval.first = std::min(interval.first, t); + interval.second = std::max(interval.second, t); + } + return interval; +} + +bool intervals_overlap(const std::pair& a, const std::pair& b, double eps = 1.e-9) { + return a.first <= b.second + eps && b.first <= a.second + eps; +} + +bool obb_overlap(const std::array& a, const std::array& b, double eps = 1.e-9) { + auto has_separating_axis = [&](const std::array& points) { + for (size_t i = 0; i < points.size(); ++i) { + auto edge = points[(i + 1) % points.size()] - points[i]; + auto axis = unit(perpendicular(edge)); + if (axis.squared_length() < 1.e-18) { + continue; + } + if (!intervals_overlap(projected_interval_on_axis(a, axis), projected_interval_on_axis(b, axis), eps)) { + return true; + } + } + return false; + }; + + return !has_separating_axis(a) && !has_separating_axis(b); +} + +template +bool obb_overlap(const T& a, const U& b, double eps = 1.e-9) { + return obb_overlap(a.corners, b.corners, eps); +} + CenterLineGraphData make_center_line_graph_data( const std::map>& line_graph, - const std::map& midpoint_to_edge_length) + const std::map>& midpoint_to_segment) { CenterLineGraphData graph; std::map point_to_index; @@ -1054,9 +1141,13 @@ CenterLineGraphData make_center_line_graph_data( auto i = graph.points.size(); point_to_index[p] = i; graph.points.push_back(p); + auto mit = midpoint_to_segment.find(p); + if (mit == midpoint_to_segment.end()) { + graph.orig_segments.emplace_back(); + } else { + graph.orig_segments.emplace_back(mit->second); + } graph.points_double.push_back(to_double_point(p)); - auto wt = midpoint_to_edge_length.find(p); - graph.widths.push_back(wt == midpoint_to_edge_length.end() ? 0. : wt->second); graph.incident_edges.emplace_back(); return i; }; @@ -1090,7 +1181,41 @@ CenterLineGraphData make_center_line_graph_data( } double segment_width(const CenterLineGraphData& graph, const std::pair& edge) { - return 0.5 * (graph.widths[edge.first] + graph.widths[edge.second]); + auto s1 = graph.orig_segments[edge.first]; + auto s2 = graph.orig_segments[edge.second]; + if (!s1 || !s2) { + throw std::runtime_error("!!!"); + } + + // A line segment between two points is expected to span a triangle, which means that one of the + // segment points ought to be shared. + Point_2 refpoint; + if (s1->first == s2->first) { + refpoint = s1->first; + } else if (s1->second == s2->first) { + refpoint = s1->second; + } else if (s1->first == s2->second) { + refpoint = s1->first; + } else if (s1->second == s2->second) { + refpoint = s1->second; + } else { + throw std::runtime_error("!!!!!"); + } + + auto p1 = graph.points_double[edge.first]; + auto p2 = graph.points_double[edge.second]; + auto v = p2 - p1; + + if (v.squared_length() < 1.e-9) { + throw std::runtime_error("!!!!!!!"); + } + + v /= std::sqrt(v.squared_length()); + auto n = perpendicular(v); + auto P = to_double_point(refpoint); + auto l = CGAL::abs((P - p1) * n); + + return 2 * l; } bool edge_supports_same_line( @@ -1181,6 +1306,7 @@ std::vector runs_from_graph(const CenterLineGraphData& graph, double an auto len = std::sqrt(d.squared_length()); total_length += len; weighted_width_sum += len * segment_width(graph, edge); + // std::cout << " l: " << len << " w: " << segment_width(graph, edge) << " p1: " << graph.points_double[edge.first] << " p2: " << graph.points_double[edge.second] << std::endl; } auto run_direction = direction_sum.squared_length() < 1.e-18 ? ref : unit(direction_sum); @@ -1203,6 +1329,8 @@ std::vector runs_from_graph(const CenterLineGraphData& graph, double an auto avg_width = total_length < 1.e-9 ? segment_width(graph, seed_edge) : weighted_width_sum / total_length; + // std::cout << "avg_width: " << avg_width << std::endl; + runs.push_back({ graph.points[start_index], graph.points[end_index], @@ -1329,9 +1457,19 @@ std::pair merge_score(const MergedBoxRecord& a, const MergedBoxR } bool clusters_can_merge(const BoxCluster& a, const BoxCluster& b, double angle_tol_deg = 5., double axis_overlap_ratio_limit = 0.5) { + auto min_width = a.box.avg_width < b.box.avg_width ? a.box.avg_width : b.box.avg_width; + auto max_width = a.box.avg_width > b.box.avg_width ? a.box.avg_width : b.box.avg_width; + if (min_width > 1.e-9) { + if (max_width / min_width > 5) { + return false; + } + } if (!aabb_overlap(a.box.bbox, b.box.bbox)) { return false; } + if (!obb_overlap(a.box, b.box)) { + return false; + } if (angle_between_dirs_deg(a.box.direction, b.box.direction) > angle_tol_deg) { return false; } @@ -1381,6 +1519,7 @@ std::vector merge_intersecting_parallel_boxes_iterative(const s std::vector members = clusters[i].members; members.insert(members.end(), clusters[j].members.begin(), clusters[j].members.end()); auto merged = BoxCluster{members, merge_cluster_to_box(members, records)}; + // std::cout << "Result width: " << merged.box.avg_width << "; from " << clusters[i].box.avg_width << " & " << clusters[j].box.avg_width << std::endl; std::vector next_clusters; next_clusters.reserve(clusters.size() - 1); @@ -1460,9 +1599,10 @@ double point_to_oriented_box_distance(const DPoint& p, const MergedBoxRecord& bo } std::map> snap_points_to_box_axes( + DebugWriter& debug, const CenterLineGraphData& graph, - const std::vector& boxes) -{ + const std::vector& boxes, + const K::FT& max_projection_distance) { std::vector snapped_points(graph.points.size()); for (size_t i = 0; i < graph.points.size(); ++i) { @@ -1502,16 +1642,34 @@ std::map> snap_points_to_box_axes( auto& c2 = containing[1]; if (angle_between_dirs_deg(boxes[c1.box_index].direction, boxes[c2.box_index].direction) > 8.) { if (auto x = intersect_infinite_lines_exact(boxes[c1.box_index], boxes[c2.box_index])) { - snapped_points[i] = *x; - continue; + auto seg = CGAL::Segment_2(graph.points[i], *x); + bool intersects_with_other_box_axis = false; + for (size_t j = 0; j < boxes.size(); ++j) { + if (j == c1.box_index || j == c2.box_index) { + continue; + } + auto& box = boxes[j]; + auto box_seg = CGAL::Segment_2(box.exact_start, box.exact_end); + if (CGAL::do_intersect(seg, box_seg)) { + intersects_with_other_box_axis = true; + break; + } + } + if (!intersects_with_other_box_axis) { + snapped_points[i] = *x; + debug.write_segment(graph.points[i], *x, "snap_candidate_1"); + continue; + } } } - snapped_points[i] = c1.projection; + snapped_points[i] = (c1.projection - graph.points[i]).squared_length() < (c2.projection - graph.points[i]).squared_length() ? c1.projection : c2.projection; + debug.write_segment(graph.points[i], snapped_points[i], "snap_candidate_2"); continue; } if (containing.size() == 1) { snapped_points[i] = containing[0].projection; + debug.write_segment(graph.points[i], containing[0].projection, "snap_candidate_3"); continue; } @@ -1521,7 +1679,14 @@ std::map> snap_points_to_box_axes( } return a.line_distance < b.line_distance; }); - snapped_points[i] = best.projection; + + if ((graph.points[i] - best.projection).squared_length() < (max_projection_distance * max_projection_distance)) { + snapped_points[i] = best.projection; + debug.write_segment(graph.points[i], best.projection, "snap_candidate_4"); + } else { + snapped_points[i] = graph.points[i]; + std::cout << "Warning: snapping distance exceeding distance: " << std::sqrt(CGAL::to_double((snapped_points[i] - best.projection).squared_length())) << " > " << max_projection_distance << std::endl; + } } std::map> adjacency; @@ -1545,9 +1710,9 @@ std::map> snap_points_to_box_axes( Graph2D join_segment_runs( DebugWriter& debug, const std::map>& line_graph, - const std::map& midpoint_to_edge_length) -{ - auto graph = make_center_line_graph_data(line_graph, midpoint_to_edge_length); + const std::map>& midpoint_to_segment, + const K::FT& max_projection_distance) { + auto graph = make_center_line_graph_data(line_graph, midpoint_to_segment); auto runs = runs_from_graph(graph); runs.erase(std::remove_if(runs.begin(), runs.end(), [](const LineRun& run) { return run.vertex_count <= 5; @@ -1577,7 +1742,7 @@ Graph2D join_segment_runs( } debug.write_polygons(run_polygons, "merged_boxes"); - auto snapped_graph = snap_points_to_box_axes(graph, boxes); + auto snapped_graph = snap_points_to_box_axes(debug, graph, boxes, max_projection_distance); return Graph2D(snapped_graph); } @@ -2069,6 +2234,208 @@ std::list> extend_end_vertices_based_on_input( return constructed_segments; } +std::list> +extend_end_vertices_based_on_input_simple( + DebugWriter& debug_output, + const Graph2D& G, + const Polygon_list& outer_perimiter, + const K::FT& max_projection_distance, int pass) +{ + auto max_intersection_distance = max_projection_distance / 4; + + using ValidationSegmentList = std::list>; + using ValidationSegmentIt = ValidationSegmentList::iterator; + using ValidationTreeTraits = CGAL::AABB_traits>; + using ValidationTree = CGAL::AABB_tree; + + const auto& to_3d = [](const Point_2& p) { + return CGAL::Point_3(p.x(), p.y(), 0); + }; + + const auto& to_2d = [](const CGAL::Point_3& p) { + return CGAL::Point_2(p.x(), p.y()); + }; + + ValidationSegmentList validation_segments; + for (auto it = G.edges_begin(); it != G.edges_end(); ++it) { + if (it->first != it->second) { + validation_segments.emplace_back(to_3d(it->first), to_3d(it->second)); + } + } + + ValidationTree validation_tree(validation_segments.begin(), validation_segments.end()); + + const auto has_intersection = [&](const Segment_2& candidate) { + // @nb still disabled. + return false; + std::vector intersected_segments; + validation_tree.all_intersected_primitives(CGAL::Segment_3(to_3d(candidate.source()), to_3d(candidate.target())), std::back_inserter(intersected_segments)); + + for (auto it : intersected_segments) { + auto existing = CGAL::Segment_2(to_2d(it->source()), to_2d(it->target())); + auto intersection = CGAL::intersection(candidate, existing); + if (!intersection) { + continue; + } + + if (auto* point = variant_get(&*intersection)) { + const bool candidate_endpoint = *point == candidate.source() || *point == candidate.target(); + const bool existing_endpoint = *point == existing.source() || *point == existing.target(); + if (candidate_endpoint && existing_endpoint) { + continue; + } + } + + return true; + } + + return false; + }; + + const auto& process_point = [&](const Point_2& M, const Point_2& incoming) { + bool within_any_perimeter = false; + for (auto& bnd : outer_perimiter) { + // if point M is contained in bnd interior: + // if (!bnd.has_on_unbounded_side(M)) { + if (bnd.has_on_bounded_side(M)) { + within_any_perimeter = true; + // create ray incoming -> M + CGAL::Ray_2 ray(incoming, M - incoming); + + // intersect ray with boundary + boost::optional> closest_segment; + boost::optional> closest_intersection_point; + K::FT sq_distance_along_ray = std::numeric_limits::infinity(); + for (auto jt = bnd.edges_begin(); jt != bnd.edges_end(); ++jt) { + const auto& seg = *jt; + auto x = CGAL::intersection(ray, seg); + if (x) { + if (auto* xp = variant_get>(&*x)) { + auto dist = ((*xp) - M).squared_length(); + if (dist < sq_distance_along_ray) { + if (dist < (max_intersection_distance * max_intersection_distance)) { + if (has_intersection(CGAL::Segment_2(M, *xp))) { + debug_output.write_segment(M, *xp, "exterior_extension_intersection"); + } else { + closest_segment = seg; + closest_intersection_point = *xp; + sq_distance_along_ray = dist; + } + } else { + } + } + } + } + } + + if (closest_intersection_point) { + return closest_intersection_point; + // constructed_segments.push_front({M, *closest_intersection_point}); + } else { + + // Loop over boundary segments, and project point onto it, take the closest + K::FT closest_distance = std::numeric_limits::infinity(); + boost::optional> closest_point; + for (auto& poly : outer_perimiter) { + for (auto jt = poly.edges_begin(); jt != poly.edges_end(); ++jt) { + auto seg = *jt; + auto Pp = seg.supporting_line().projection(M); + if (seg.has_on(Pp)) { + auto d = CGAL::squared_distance(Pp, M); + if (d < (max_projection_distance * max_projection_distance)) { + if (d < closest_distance) { + if (has_intersection(CGAL::Segment_2(M, Pp))) { + debug_output.write_segment(M, Pp, "exterior_projection_intersection"); + } else { + closest_distance = d; + closest_point = Pp; + } + } + } + } + } + } + + if (closest_point) { + return closest_point; + // constructed_segments.push_front({M, *closest_point}); + } else { + + for (auto& poly : outer_perimiter) { + for (auto it = poly.begin(); it != poly.end(); ++it) { + auto Pp = *it; + auto d = CGAL::squared_distance(Pp, M); + if (d < (max_projection_distance * max_projection_distance)) { + if (has_intersection(CGAL::Segment_2(M, Pp))) { + debug_output.write_segment(M, Pp, "exterior_nearby_intersection"); + } else { + if (d < closest_distance) { + closest_distance = d; + closest_point = Pp; + } + } + } + } + } + + if (closest_point) { + return closest_point; + } else { + } + } + } + } else if (bnd.has_on_boundary(M)) { + return boost::optional{M}; + } + } + if (within_any_perimeter) { + std::cout << "Within boundary but still no solution given" << std::endl; + } else { + std::cout << "Outside of all boundaries" << std::endl; + } + return boost::optional{}; + }; + + using solution_length_point_incoming = std::tuple; + std::vector solutions; + + for (auto it = G.vertices_begin(); it != G.vertices_end(); ++it) { + if (it->second.size() == 1) { + auto& M = it->first; + if (auto result = process_point(M, *it->second.begin())) { + if (*result == M) { + std::cout << "Point already on perimeter (" << M.x() << " " << M.y() << ")" << std::endl; + continue; + } + auto d = (M - *result).squared_length(); + solutions.emplace_back(d, M, *it->second.begin()); + } else { + std::cout << "Unable to find projection or intersection point for interior boundary pass " << pass << " [round 1] (" << M.x() << " " << M.y() << ")" << std::endl; + } + } + } + + std::sort(solutions.begin(), solutions.end()); + std::list> constructed_segments; + + for (auto& [d, point, incoming] : solutions) { + if (auto result = process_point(point, incoming)) { + constructed_segments.push_front({point, *result}); + debug_output.write_segment(point, *result, "exterior_constructed_segment"); + + auto d = CGAL::squared_distance(point, *result); + std::cout << "Distance: " << std::sqrt(CGAL::to_double(d)) << std::endl; + validation_segments.emplace_back(to_3d(point), to_3d(*result)); + auto inserted_it = std::prev(validation_segments.end()); + validation_tree.insert(inserted_it, validation_segments.end()); + } else { + std::cout << "Unable to find projection or intersection point for interior boundary pass " << pass << " [round 2] (" << point.x() << " " << point.y() << ")" << std::endl; + } + } + + return constructed_segments; +} + void fuse_corridor_halves_with_input(Arrangement_2& arr, Graph2D& G, SegmentLookup& segment_lookup, const Polygon_list& input_polygons, DebugWriter& debug_output) { std::set edges_to_remove; @@ -2161,7 +2528,7 @@ class Segment_2_less { } }; -std::vector arrangement_cell_iou(Arrangement_2& left, Arrangement_2& right) { +std::vector arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2& left, Arrangement_2& right) { using Walk_pl = CGAL::Arr_walk_along_line_point_location; Walk_pl walk_pl(right); @@ -2170,6 +2537,9 @@ std::vector arrangement_cell_iou(Arrangement_2& left, Arrangement_2& righ std::vector return_values; + K::FT max_iou_deviation = 1; + std::array max_deviation_poly_pair; + for (auto it = left.faces_begin(); it != left.faces_end(); ++it) { if (!it->is_unbounded()) { // convert arr facet to polygon with holes @@ -2178,6 +2548,9 @@ std::vector arrangement_cell_iou(Arrangement_2& left, Arrangement_2& righ for (auto hit = it->inner_ccbs_begin(); hit != it->inner_ccbs_end(); ++hit) { pwh.add_hole(circ_to_poly(*hit)); } + // if (!pwh.outer_boundary().is_simple()) { + // throw std::runtime_error("Polygon with holes has a non-simple outer boundary"); + // } CGAL::Polygon_triangulation_decomposition_2 decompositor; std::vector temp; @@ -2218,10 +2591,26 @@ std::vector arrangement_cell_iou(Arrangement_2& left, Arrangement_2& righ } } + if (max_score == -std::numeric_limits::infinity()) { + // no more points to try + return_values.push_back(0); + break; + } + + visited_points.insert(best_point); + + debug_output.write_point(best_point, "representative_point representative_point_" + std::to_string(std::distance(left.faces_begin(), it))); + auto res = walk_pl.locate(best_point); if (auto* v = variant_get(&res)) { + if ((*v)->is_unbounded()) { + // try next point + continue; + } if (visited_faces_on_right.count(*v) > 0) { + // Maybe we should be more permissive, try some other points etc. return_values.push_back(0); + std::cout << "Already visited face on right, skipping point\n"; } else { // convert arr facet to polygon with holes auto polygon_exterior = circ_to_poly((*v)->outer_ccb()); @@ -2229,6 +2618,9 @@ std::vector arrangement_cell_iou(Arrangement_2& left, Arrangement_2& righ for (auto hit = (*v)->inner_ccbs_begin(); hit != (*v)->inner_ccbs_end(); ++hit) { pwh_right.add_hole(circ_to_poly(*hit)); } + // if (!pwh_right.outer_boundary().is_simple()) { + // throw std::runtime_error("Polygon with holes has a non-simple outer boundary"); + // } // compute intersection over union of pwh and the original polygon if (CGAL::do_intersect(pwh, pwh_right)) { @@ -2238,7 +2630,7 @@ std::vector arrangement_cell_iou(Arrangement_2& left, Arrangement_2& righ for (auto& r : result) { auto poly_area = r.outer_boundary().area(); for (auto& h : r.holes()) { - poly_area -= h.area(); + poly_area -= CGAL::abs(h.area()); } intersection_area += poly_area; } @@ -2246,10 +2638,17 @@ std::vector arrangement_cell_iou(Arrangement_2& left, Arrangement_2& righ CGAL::join(pwh, pwh_right, poly12); typename K::FT union_area = poly12.outer_boundary().area(); for (auto& h : poly12.holes()) { - union_area -= h.area(); + union_area -= CGAL::abs(h.area()); } return_values.push_back(intersection_area / union_area); + + auto& v = return_values.back(); + if (v < max_iou_deviation) { + max_iou_deviation = v; + max_deviation_poly_pair = {pwh.outer_boundary(), pwh_right.outer_boundary()}; + } } else { + std::cout << "No intersection, skipping point\n"; return_values.push_back(0); } } @@ -2263,6 +2662,11 @@ std::vector arrangement_cell_iou(Arrangement_2& left, Arrangement_2& righ } } + if (max_iou_deviation != 1) { + debug_output.write_polygon(max_deviation_poly_pair[0], "max_iou_deviation_left"); + debug_output.write_polygon(max_deviation_poly_pair[1], "max_iou_deviation_right"); + } + return return_values; } @@ -2935,7 +3339,20 @@ class timer { bool enabled_; }; +size_t delete_same_facet_edge_pairs(Arrangement_2& arr) { + size_t n_deleted = 0; + for (auto it = arr.edges_begin(); it != arr.edges_end();) { + decltype(it) current = it++; + if (current->face() == current->twin()->face()) { + arr.remove_edge(current); + n_deleted++; + } + } + return n_deleted; +} + void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std::vector& input_polygons_, std::vector& output_polygons, double polygon_offset_distance = -1.) { + static const double OVERLAP_RESOLUTION_DISTANCE = 1.e-1; // even larger amount of inset so that outer perimeter is safely within all input polygons even when overlap resolution is applied // no, `1.e-2 + 1.e-5` creates issues with the outer perimeter, are there other tolerances in play? @@ -2979,6 +3396,14 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std std::swap(input_polygons, split_polygons); } + // before overlap elimition we can (and should) still smooth + /* + * @todo + for (auto& r : input_polygons) { + smooth_polygon(polygon_offset_distance / 100., r); + } + */ + t0.stop(); t0 = timer.start("overlap elimination"); @@ -3097,13 +3522,17 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std t0.stop(); t0 = timer.start("corridor triangulation"); + SegmentLookup segment_lookup(input_polygons); + // subdivide difference_result to have better more detailed triangulation and therefore less-pronounced artefacts in midpoint network + // We store correspondence of subdivision points to input polygons when subdividing so that we do not need to query, which is expensive, when building the line graph later on. + std::map point_lookup; + auto subdivision_length = polygon_offset_distance / settings.subdivision_factor; for (auto& pwh : difference_result) { - difference_result_subdivided.push_back(subdivide_polygon(subdivision_length, pwh)); - // difference_result_subdivided.push_back(subdivide_polygon(polygon_offset_distance / 64., pwh)); + difference_result_subdivided.push_back(subdivide_polygon_on_same_input(segment_lookup, subdivision_length, pwh, point_lookup)); } debug_output.write_polygons(difference_result_subdivided, "corridor_subdivided"); @@ -3128,9 +3557,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std debug_output.write_polygons(triangular_polygons, "triangulated_corridor"); - SegmentLookup segment_lookup(input_polygons); - - auto [line_graph, midpoint_to_segment, segment_to_input_facet, midpoint_to_edge_length] = build_line_graph(input_polygons, segment_lookup, triangular_polygons); + auto [line_graph, midpoint_to_segment, segment_to_input_facet] = build_line_graph(input_polygons, point_lookup, triangular_polygons); for (auto& p : line_graph) { for (auto& q : p.second) { debug_output.write_segment(p.first, q, "network_1"); @@ -3142,8 +3569,45 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std t0 = timer.start("center line cleaning"); Graph2D G; + + { + // this is applied for both algos + auto eliminated_segments = eliminate_triangles(line_graph); + for (auto e : eliminated_segments) { + debug_output.write_segment(e.first, e.second, "eliminated"); + for (int i = 0; i < 2; ++i) { + auto it = line_graph.find(e.first); + if (it == line_graph.end()) { + std::cerr << "Warning: unable to locate vertex for elimination, skipping" << std::endl; + continue; + } + auto& neighbours = it->second; + neighbours.erase(std::remove(neighbours.begin(), neighbours.end(), e.second), neighbours.end()); + if (neighbours.empty()) { + line_graph.erase(it); + } + std::swap(e.first, e.second); + } + } + } + + Graph2D G_orig(line_graph); + + auto apply_line_cleaning_algo_1 = [&]() { + Graph2D G2(line_graph); + G = G2.weld_vertices(); + for (auto it = G.edges_begin(); it != G.edges_end(); ++it) { + debug_output.write_segment(it->first, it->second, "network_b_2"); + } + eliminate_colinear_vertices(G); + edge_slide(G); + for (auto it = G.edges_begin(); it != G.edges_end(); ++it) { + debug_output.write_segment(it->first, it->second, "network_b_3"); + } + }; + if (settings.line_cleaning_algo == 0) { - G = join_segment_runs(debug_output, line_graph, midpoint_to_edge_length); + G = join_segment_runs(debug_output, line_graph, midpoint_to_segment, subdivision_length * 4); Arrangement_2 arr; G.to_arrangement(arr); Graph2D G2; @@ -3151,37 +3615,81 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std eliminate_colinear_vertices(G2); G = G2; for (auto it = G.edges_begin(); it != G.edges_end(); ++it) { - debug_output.write_segment(it->first, it->second, "network_2"); + debug_output.write_segment(it->first, it->second, "network_a_2"); } } else { - auto eliminated_segments = eliminate_triangles(line_graph); - - Graph2D G2(line_graph); - for (auto& e : eliminated_segments) { - debug_output.write_segment(e.first, e.second, "eliminated"); - G2.remove_edge(e.first, e.second); - } - - G = G2.weld_vertices(); - - for (auto it = G.edges_begin(); it != G.edges_end(); ++it) { - debug_output.write_segment(it->first, it->second, "network_2"); - } - - eliminate_colinear_vertices(G); - - edge_slide(G); - - for (auto it = G.edges_begin(); it != G.edges_end(); ++it) { - debug_output.write_segment(it->first, it->second, "network_3"); - } + apply_line_cleaning_algo_1(); } t0.stop(); t0 = timer.start("topology"); - auto segments = extend_end_vertices_based_on_input(G, midpoint_to_segment, segment_to_input_facet, outer_perimiter, segment_lookup, subdivision_length * 4); + std::list> segments, segments1, segments2; + bool fallback_to_line_cleaning_algo_1 = false; + + if (settings.line_cleaning_algo == 0) { + segments1 = extend_end_vertices_based_on_input_simple(debug_output, G, outer_perimiter, subdivision_length * 16, 0); + segments2 = extend_end_vertices_based_on_input_simple(debug_output, G_orig, outer_perimiter, subdivision_length * 16, 1); + + Arrangement_2 arr_clean; + G.to_arrangement(arr_clean); + for (auto& pq : segments1) { + if (pq.first == pq.second) { + continue; + } + CGAL::insert(arr_clean, Segment_2(pq.first, pq.second)); + } + + Arrangement_2 arr_orig; + G_orig.to_arrangement(arr_orig); + for (auto& pq : segments2) { + if (pq.first == pq.second) { + continue; + } + CGAL::insert(arr_orig, Segment_2(pq.first, pq.second)); + } + + for (auto& p : outer_perimiter) { + for (auto it = p.edges_begin(); it != p.edges_end(); ++it) { + auto source = it->source(); + auto target = it->target(); + if (source == target) { + continue; + } + CGAL::insert(arr_orig, Segment_2(source, target)); + CGAL::insert(arr_clean, Segment_2(source, target)); + } + } + + delete_same_facet_edge_pairs(arr_clean); + delete_same_facet_edge_pairs(arr_orig); + + debug_output.write_polygons(arr_clean, "iou_left"); + debug_output.write_polygons(arr_orig, "iou_right"); + + auto ious = arrangement_cell_iou(debug_output, arr_clean, arr_orig); + /* + for (auto& iou : ious) { + std::cout << " " << CGAL::to_double(iou - 1); + } + std::cout << std::endl; + */ + + auto it = std::min_element(ious.begin(), ious.end()); + + if (it != ious.end() && (*it < 0.45)) { + std::cerr << "Significant difference between cleaned and original arrangement, using original for topology reconstruction: " << *it << std::endl; + fallback_to_line_cleaning_algo_1 = true; + apply_line_cleaning_algo_1(); + } else { + segments = segments1; + } + } + + if (settings.line_cleaning_algo != 0 || fallback_to_line_cleaning_algo_1) { + segments = extend_end_vertices_based_on_input(G, midpoint_to_segment, segment_to_input_facet, outer_perimiter, segment_lookup, subdivision_length * 4); + } // Now plot the edges on an arrangement in order to find planar cycles // and merge the corridor-halves with their neighbouring input polygon @@ -3223,15 +3731,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std } } - // Just for the automatic numbering, create a full vector - std::vector temp; - for (auto it = arr.faces_begin(); it != arr.faces_end(); ++it) { - if (it->is_unbounded()) { - continue; - } - temp.push_back(circ_to_poly(it->outer_ccb())); - } - debug_output.write_polygons(temp, "arr_faces"); + debug_output.write_polygons(arr, "arr_faces"); /* { @@ -3256,7 +3756,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std double threshold; clean_noisy_paths(debug_output, arr, segment_lookup, threshold); remove_colinear_vertices(arr); - clean_noisy_bounds(debug_output, arr, segment_lookup, threshold); + // clean_noisy_bounds(debug_output, arr, segment_lookup, threshold); } t0.stop(); diff --git a/src/svgfill/src/graph_2d.h b/src/svgfill/src/graph_2d.h index d19418ff62..76d8200d4c 100644 --- a/src/svgfill/src/graph_2d.h +++ b/src/svgfill/src/graph_2d.h @@ -178,6 +178,9 @@ public: std::vector> segments; for (const auto& p : adjacency_list) { for (const auto& q : p.second) { + if (p.first == q) { + return false; + } if (p.first < q) { segments.emplace_back(p.first, q); } @@ -198,7 +201,7 @@ public: any = true; } }); - return any; + return !any; } // Eliminates a vertex with exactly two neighbors by connecting its neighbors @@ -338,12 +341,21 @@ public: template void to_arrangement(T& arr) { - for (auto it = edges_begin(); it != edges_end(); ++it) { - if (it->first == it->second) { - continue; + if (is_valid() && arr.is_empty()) { + std::vector> edges; + + for (auto it = edges_begin(); it != edges_end(); ++it) { + edges.emplace_back(it->first, it->second); } - CGAL::insert(arr, CGAL::Segment_2(it->first, it->second)); - } + CGAL::insert_non_intersecting_curves(arr, edges.begin(), edges.end()); + } else { + for (auto it = edges_begin(); it != edges_end(); ++it) { + if (it->first == it->second) { + continue; + } + CGAL::insert(arr, CGAL::Segment_2(it->first, it->second)); + } + } } template