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 4c6cb9312c..c758470a79 100644
--- a/.github/workflows/build_osx.yml
+++ b/.github/workflows/build_osx.yml
@@ -53,7 +53,7 @@ jobs:
python ../nix/cache_dependencies.py unpack
- name: ccache
- uses: hendrikmuhs/ccache-action@v1.2.22
+ uses: hendrikmuhs/ccache-action@v1.2.23
with:
key: mac-${{ matrix.arch }}
diff --git a/.github/workflows/build_pyodide.yml b/.github/workflows/build_pyodide.yml
index a5e80a32df..050b542cff 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 9ac489378b..0de26089f6 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
@@ -17,7 +24,6 @@ jobs:
sqlite-devel bzip2-devel zlib-devel openssl-devel xz-devel \
readline-devel ncurses-devel libffi-devel libuuid-devel git-lfs \
findutils xz byacc
- python3 -m pip install typing_extensions
git config --global --add safe.directory '*'
- name: Install aws cli
@@ -45,10 +51,10 @@ jobs:
- name: Unpack Dependencies
run: |
cd build
- python3 ../nix/cache_dependencies.py unpack
+ uv run ../nix/cache_dependencies.py unpack
- name: ccache
- uses: hendrikmuhs/ccache-action@v1.2.22
+ uses: hendrikmuhs/ccache-action@v1.2.23
with:
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
@@ -56,7 +62,7 @@ jobs:
shell: bash
run: |
set -o pipefail
- CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release python3 ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log
+ CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release uv run ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log
- name: Upload Build Logs
if: always()
@@ -71,7 +77,7 @@ jobs:
- name: Pack Dependencies
run: |
cd build
- python3 ../nix/cache_dependencies.py pack
+ uv run ../nix/cache_dependencies.py pack
- name: Commit and Push Changes to Build Repository
run: |
diff --git a/.github/workflows/build_rocky_arm.yml b/.github/workflows/build_rocky_arm.yml
index 208e1da9da..e7401f1f63 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
@@ -17,7 +24,6 @@ jobs:
sqlite-devel bzip2-devel zlib-devel openssl-devel xz-devel \
readline-devel ncurses-devel libffi-devel libuuid-devel git-lfs \
findutils xz byacc
- python3 -m pip install typing_extensions
git config --global --add safe.directory '*'
- name: Install aws cli
@@ -45,10 +51,10 @@ jobs:
- name: Unpack Dependencies
run: |
cd build
- python3 ../nix/cache_dependencies.py unpack
+ uv run ../nix/cache_dependencies.py unpack
- name: ccache
- uses: hendrikmuhs/ccache-action@v1.2.22
+ uses: hendrikmuhs/ccache-action@v1.2.23
with:
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
@@ -56,7 +62,7 @@ jobs:
shell: bash
run: |
set -o pipefail
- CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release python3 ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log
+ CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release uv run ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log
- name: Upload Build Logs
if: always()
@@ -71,7 +77,7 @@ jobs:
- name: Pack Dependencies
run: |
cd build
- python3 ../nix/cache_dependencies.py pack
+ uv run ../nix/cache_dependencies.py pack
- name: Commit and Push Changes to Build Repository
run: |
diff --git a/.github/workflows/build_win.yml b/.github/workflows/build_win.yml
index 7880296a2a..5b976f7886 100644
--- a/.github/workflows/build_win.yml
+++ b/.github/workflows/build_win.yml
@@ -52,7 +52,7 @@ jobs:
}
- name: ccache
- uses: hendrikmuhs/ccache-action@v1.2.22
+ uses: hendrikmuhs/ccache-action@v1.2.23
with:
key: win-${{ matrix.arch }}
# Windows ccache needs ~1GB
diff --git a/.github/workflows/ci-ifcopenshell-docker.yml b/.github/workflows/ci-ifcopenshell-docker.yml
index df672139b8..50978c9d6f 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 a49c92fcd7..2801d19ecc 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:
libhdf5-dev libcgal-dev libeigen3-dev
- name: ccache
- uses: hendrikmuhs/ccache-action@v1.2.22
+ uses: hendrikmuhs/ccache-action@v1.2.23
with:
key: ubuntu-22.04-${{ runner.arch }}
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/nix/build-all.py b/nix/build-all.py
index a4e261fc70..fd2abe2a11 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. #
@@ -124,16 +126,9 @@ ssl._create_default_https_context = ssl._create_unverified_context
import time
from collections.abc import Generator, Sequence
from pathlib import Path
+from typing import Literal, Union
from urllib.request import urlretrieve
-try:
- from typing import Literal, Union
-except:
- # python 3.6 compatibility for rocky 8
- from typing import Union
-
- from typing_extensions import Literal
-
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
ch = logging.StreamHandler()
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 db5c5f08b0..8afbb3fcce 100755
--- a/pyodide/build_pyodide.sh
+++ b/pyodide/build_pyodide.sh
@@ -1,6 +1,11 @@
#!/usr/bin/bash
set -ex
+PYODIDE_VERSION=0.29.3
+PYODIDE_BUILD_VERSION=0.33.0
+PYODIDE_XBUILDENV_ROOT="${HOME}/.cache/.pyodide-xbuildenv-${PYODIDE_BUILD_VERSION}"
+PYODIDE_XBUILDENV="${PYODIDE_XBUILDENV_ROOT}/${PYODIDE_VERSION}"
+
# Script is assuming that it will be possible to execute it multiple times
# therefore we're clearing venv each time and ignoring existing 'emsdk' folder.
@@ -11,14 +16,15 @@ source .venv/bin/activate
# Install pyodide cross build environment.
# Instructions: https://pyodide.org/en/stable/development/building-packages.html
-uv pip install pyodide-build
+uv pip install "pyodide-build==${PYODIDE_BUILD_VERSION}"
# `uv run` is required, so xbuildenv would skip using `pip`.
-uv run pyodide xbuildenv install
+uv run pyodide xbuildenv install "${PYODIDE_VERSION}"
uv run pyodide xbuildenv install-emscripten
-EMSDK_ROOT=$(pyodide config get emscripten_dir)
-source ${EMSDK_ROOT}/emsdk_env.sh
+EMSDK_ROOT="${PYODIDE_XBUILDENV}/emsdk"
+source "${EMSDK_ROOT}/emsdk_env.sh"
which emcc
+emcc --version
mkdir -p packages/ifcopenshell
VERSION=`cat IfcOpenShell/VERSION`
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..e00d77da8e 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
diff --git a/src/bonsai/bonsai/bim/__init__.py b/src/bonsai/bonsai/bim/__init__.py
index 4302739aab..b1e105a079 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
@@ -27,6 +29,18 @@ from bpy_extras.io_utils import ExportHelper, ImportHelper
from . import handler, operator, prop, ui
+
+def _parametric_gizmo_preference_classes() -> list[type]:
+ """Resolves the registry-driven ``GizmoPreferences`` classes for the
+ ``classes`` list below. ``import bonsai.tool`` is kept local to surface
+ the load-order constraint: it relies on ``from . import handler, …``
+ above having primed the
+ ``tool/ifc.py → bim/ifc.py → bim/handler.py → bonsai.tool`` cycle."""
+ import bonsai.tool as tool
+
+ return tool.Parametric.iter_gizmo_preference_classes(ui)
+
+
try:
from bonsai.translations import translations_dict
except ImportError:
@@ -157,9 +171,10 @@ classes = [
ui.BIM_UL_tab_visibilities,
ui.BIM_UL_panel_visibilities,
ui.DocPreferences,
- ui.GizmoPreferencesDoor, # Register before GizmoPreferences
- ui.GizmoPreferencesWindow, # Register before GizmoPreferences
- ui.GizmoPreferencesStair, # Register before GizmoPreferences
+ # Per-parametric-type ``GizmoPreferences`` classes — must register
+ # before ``ui.GizmoPreferences`` which holds the matching PointerProperty
+ # fields. Driven by ``tool.Parametric.EDIT_TYPES``.
+ *_parametric_gizmo_preference_classes(),
ui.GizmoPreferences,
# ui.DefaultParameters and ui.BIM_ADDON_preferences are registered separately after modules (see late_classes below)
# Tabs panel
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/handler.py b/src/bonsai/bonsai/bim/handler.py
index e11eb07ce8..38c7990653 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,6 +32,7 @@ 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.module.aggregate.decorator import AggregateDecorator
@@ -133,14 +135,32 @@ 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()
- 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
@@ -165,7 +185,9 @@ def update_bim_tool_props():
if AuthoringData.data["active_material_usage"] == "LAYER2":
x_angle = get_x_angle(extrusion)
axis = tool.Model.get_wall_axis(obj)["reference"]
- props.extrusion_depth = abs(extrusion.Depth * si_conversion * cos(x_angle))
+ props.extrusion_depth = core_model.vertical_height_from_extrusion_depth(
+ extrusion.Depth * si_conversion, x_angle
+ )
props.length = (axis[1] - axis[0]).length
props.x_angle = x_angle
diff --git a/src/bonsai/bonsai/bim/ifc.py b/src/bonsai/bonsai/bim/ifc.py
index b07e584a71..453a69058f 100644
--- a/src/bonsai/bonsai/bim/ifc.py
+++ b/src/bonsai/bonsai/bim/ifc.py
@@ -514,6 +514,7 @@ class IfcStore:
BrickStore.end_transaction()
IfcStore.end_transaction(operator)
bonsai.bim.handler.refresh_ui_data()
+ tool.Parametric.refresh_post_commit()
if method == "MODAL":
cls.modal_in_progress = False
diff --git a/src/bonsai/bonsai/bim/module/drawing/__init__.py b/src/bonsai/bonsai/bim/module/drawing/__init__.py
index 9f172ce2bb..8b10314faa 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
@@ -143,6 +145,14 @@ classes = (
gizmos.GizmoCancel,
gizmos.GizmoPlus,
gizmos.GizmoMinus,
+ gizmos.GizmoMerge,
+ gizmos.GizmoSplit,
+ gizmos.GizmoExtend,
+ gizmos.GizmoExtendVertical,
+ gizmos.GizmoOffsetExterior,
+ gizmos.GizmoOffsetCenter,
+ gizmos.GizmoOffsetInterior,
+ gizmos.GizmoAddOpening,
gizmos.GizmoCycle,
# Drawing-specific gizmos
gizmos.UglyDotGizmo,
diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py
index d350cf80ee..31a0be5c80 100644
--- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py
+++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py
@@ -16,6 +16,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.
"""
Gizmo infrastructure for parametric BIM element editing.
@@ -511,6 +513,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 +525,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)
@@ -795,6 +803,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 +825,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
@@ -935,7 +946,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:
@@ -1121,6 +1139,13 @@ class DimensionGizmoConfig:
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
@@ -1138,6 +1163,7 @@ class DimensionGizmoConfig:
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
def __post_init__(self):
# Validate attr_name
@@ -1576,6 +1602,78 @@ 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 = 0.5) -> Matrix:
+ """Compose the standard icon ``matrix_basis``: translate to ``world_pos``, billboard
+ to the camera, then uniformly scale. Replaces the repeated
+ ``Matrix.Translation(...) @ billboard_rot @ Matrix.Scale(scale, 4)`` pattern."""
+ return Matrix.Translation(world_pos) @ billboard_rot @ Matrix.Scale(scale, 4)
+
+
+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 and configure a stand-alone icon gizmo with the Bonsai defaults
+ (no draw-scale, fixed alpha, click-to-operator). Use this from any
+ ``GizmoGroup.setup`` to avoid hand-rolling the same five property assignments."""
+ 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
+
+
+# --- 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)
+
+
+class TrisGizmoMixin:
+ """Mixin for stand-alone ``bpy.types.Gizmo`` classes whose only behaviour is
+ drawing a static ``tris`` triangle tuple. Subclasses set the class-level
+ ``tris`` and ``bl_idname`` attributes; the mixin supplies ``setup`` / ``draw`` /
+ ``draw_select``. Use only with gizmos that have no per-instance state beyond
+ ``custom_shape``."""
+
+ 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)
+
+
def get_camera_direction(context: bpy.types.Context, position: Vector) -> Vector | None:
"""Get normalized direction from position towards camera."""
rv3d = context.region_data
@@ -3042,6 +3140,145 @@ class GizmoMinus(bpy.types.Gizmo):
self.draw_custom_shape(self.custom_shape, select_id=select_id)
+class GizmoMerge(TrisGizmoMixin, 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(TrisGizmoMixin, 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 GizmoExtend(TrisGizmoMixin, 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(TrisGizmoMixin, 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(TrisGizmoMixin, 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(TrisGizmoMixin, 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(TrisGizmoMixin, 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(TrisGizmoMixin, 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."""
triangles = []
@@ -3421,6 +3658,7 @@ 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
)
ARROW_SIZE = 10
@@ -3479,6 +3717,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 +3744,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 +3864,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.
@@ -3913,6 +4167,59 @@ class CycleTypeMixin:
return {"FINISHED"}
+class BillboardingGizmoGroupMixin:
+ """Mixin for standalone ``bpy.types.GizmoGroup`` classes whose icons must billboard
+ (face the camera) and re-position every frame.
+
+ 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.
+
+ ``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.
+
+ Usage::
+
+ 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.
+ ...
+
+ ``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 refresh(self, context: bpy.types.Context) -> None:
+ self.position_gizmos(context)
+
+ def draw_prepare(self, context: bpy.types.Context) -> None:
+ self.position_gizmos(context)
+
+ 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)
+
+ def position_gizmos(self, context: bpy.types.Context) -> None:
+ raise NotImplementedError(
+ f"{type(self).__name__} must implement position_gizmos(context) when using BillboardingGizmoGroupMixin."
+ )
+
+
class BaseParametricGizmoGroup:
"""Base mixin for parametric element gizmo groups (doors, windows, stairs, etc.).
@@ -4129,6 +4436,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 +4557,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))
@@ -4309,21 +4642,15 @@ 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.get_addon_preferences().gizmos.draw_gizmos_in_3d_viewport:
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):
- return False
- return True
+ return bool(element) and cls.is_element_type(element)
def setup(self, context: bpy.types.Context) -> None:
"""Template method for gizmo setup.
@@ -4343,6 +4670,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 +4697,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,8 +4705,10 @@ 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
@@ -4385,10 +4728,11 @@ class BaseParametricGizmoGroup:
return getattr(tool.Model, 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.
@@ -4507,8 +4851,8 @@ class BaseParametricGizmoGroup:
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_dimension_gizmo_position(
self,
@@ -4594,28 +4938,12 @@ 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()
@@ -4696,6 +5024,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
@@ -4723,10 +5052,9 @@ class BaseParametricGizmoGroup:
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)
@@ -4758,7 +5086,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 +5102,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,34 +5110,40 @@ 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)
-
+ 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:
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,
)
else:
self.pen_gizmo.hide = self.is_gizmo_hidden_by_modal(self.pen_gizmo)
- self.pen_gizmo.matrix_basis = icon_matrix_base
+ 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:
@@ -4819,16 +5152,26 @@ class BaseParametricGizmoGroup:
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.
"""
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 +5179,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:
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/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py
index 8d075a9605..e7fa918d50 100644
--- a/src/bonsai/bonsai/bim/module/geometry/operator.py
+++ b/src/bonsai/bonsai/bim/module/geometry/operator.py
@@ -1183,7 +1183,7 @@ class OverrideDuplicateMove(bpy.types.Operator):
operator: bpy.types.Operator, context: bpy.types.Context, linked: bool = False
) -> set["rna_enums.OperatorReturnItems"]:
# Deep magick from the dawn of time
- if tool.Ifc.get():
+ if tool.Ifc.get() and tool.Model.has_selected_ifc_objects(include_active=False):
IfcStore.execute_ifc_operator(operator, context)
return {"FINISHED"}
@@ -1287,6 +1287,11 @@ class OverrideDuplicateMove(bpy.types.Operator):
if part_obj:
all_objects_to_select.add(part_obj)
+ # Non-IFC duplicates aren't tracked in old_to_new but are left selected by duplicate_ifc_objects
+ all_objects_to_select.update(
+ obj for obj in context.selected_objects if not tool.Ifc.get_entity(obj)
+ )
+
# Deselect everything first
bpy.ops.object.select_all(action="DESELECT")
diff --git a/src/bonsai/bonsai/bim/module/material/operator.py b/src/bonsai/bonsai/bim/module/material/operator.py
index 7587a7e4ed..8a54478179 100644
--- a/src/bonsai/bonsai/bim/module/material/operator.py
+++ b/src/bonsai/bonsai/bim/module/material/operator.py
@@ -630,7 +630,7 @@ 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,
diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py
index 9fbd631003..28a0eee304 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,
@@ -70,18 +74,33 @@ classes = (
workspace.BIM_MT_add_representation_item,
wall.AddWallsFromSlab,
wall.AlignWall,
+ wall.CancelEditingWall,
wall.ChangeExtrusionDepth,
wall.ChangeExtrusionXAngle,
wall.ChangeLayerLength,
+ wall.CycleWallOffset,
wall.DrawPolylineWall,
+ wall.EnableEditingWall,
+ wall.ExtendWallHeightToCursor,
wall.ExtendWallsToUnderside,
+ wall.RegenerateWallToUnderside,
wall.ExtendWallsToWall,
wall.ExtendWallsToPolylinePoint,
+ wall.ExtendWallToCursor,
+ wall.FinishEditingWall,
wall.FlipWall,
+ wall.GizmoWallAddOpening,
+ wall.GizmoWallEdition,
+ wall.GizmoWallExtendVertically,
+ wall.GizmoWallJoinIntersection,
+ wall.JoinWallsIntersection,
wall.MergeWall,
wall.OffsetWalls,
wall.RecalculateWall,
+ wall.RotateWall90,
wall.SplitWall,
+ wall.SplitWallAtCursor,
+ wall.ToggleWallOpenings,
wall.UnjoinWalls,
opening.AddBoolean,
opening.CloneOpening,
@@ -140,10 +159,12 @@ classes = (
prop.BIMDoorProperties,
prop.BIMRailingProperties,
prop.BIMRoofProperties,
+ prop.BIMWallProperties,
prop.BIMPolylineProperties,
prop.BIMExternalParametricGeometryProperties,
ui.BIM_PT_array,
ui.BIM_PT_stair,
+ ui.BIM_PT_wall,
ui.BIM_PT_sverchok,
ui.BIM_PT_window,
ui.BIM_PT_door,
@@ -264,12 +285,10 @@ 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
)
@@ -288,12 +307,8 @@ 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
bpy.app.handlers.load_post.remove(handler.load_post)
diff --git a/src/bonsai/bonsai/bim/module/model/door.py b/src/bonsai/bonsai/bim/module/model/door.py
index 5a14cde101..d6a619f429 100644
--- a/src/bonsai/bonsai/bim/module/model/door.py
+++ b/src/bonsai/bonsai/bim/module/model/door.py
@@ -38,6 +38,7 @@ import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from bonsai.bim.module.model.window import create_bm_box, create_bm_window
+from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin
if TYPE_CHECKING:
from bonsai.bim.module.model.prop import BIMDoorProperties
@@ -566,103 +567,58 @@ class AddDoor(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
-class CancelEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
+class _DoorEditMixin(FeatureModifierEditMixin):
+ """Type-specific hooks for door parametric-edit operators. Multi-object —
+ iterates ``tool.Blender.get_selected_objects()`` so a finish/cancel applies
+ to every selected door at once."""
+
+ pset_name = "BBIM_Door"
+
+ @classmethod
+ def _iter_targets(cls, context: bpy.types.Context) -> list[bpy.types.Object]:
+ return tool.Blender.get_selected_objects()
+
+ @classmethod
+ def _is_element_type(cls, element):
+ return tool.Blender.Modifier.is_door(element)
+
+ @classmethod
+ def _get_props(cls, obj: bpy.types.Object):
+ return tool.Model.get_door_props(obj)
+
+ @classmethod
+ def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
+ update_door_modifier_representation(obj)
+
+
+class CancelEditingDoor(_DoorEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_door"
bl_label = "Cancel Editing Door on Selected Objects"
bl_description = "Cancel editing and revert door parameters to their previous values"
bl_options = {"REGISTER", "UNDO"}
- def cancel_editing_door_on_object(self, obj: bpy.types.Object) -> None:
- element = tool.Ifc.get_entity(obj)
- assert element
- if not tool.Blender.Modifier.is_door(element):
- return
- props = tool.Model.get_door_props(obj)
- data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Door", "Data"))
- data.update(data.pop("lining_properties"))
- data.update(data.pop("panel_properties"))
-
- # restore previous settings since editing was canceled
- props.set_props_kwargs_from_ifc_data(data)
-
- body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
- core.switch_representation(
- tool.Ifc,
- tool.Geometry,
- obj=obj,
- representation=body,
- )
-
- props.is_editing = False
-
- def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
- for obj in tool.Blender.get_selected_objects():
- self.cancel_editing_door_on_object(obj)
- return {"FINISHED"}
+ def _execute(self, context: bpy.types.Context) -> set[str]:
+ return self._cancel_targets(context)
-class FinishEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
+class FinishEditingDoor(_DoorEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_door"
bl_label = "Finish Editing Door on Selected Objects"
bl_description = "Apply changes and finish editing door parameters"
bl_options = {"REGISTER", "UNDO"}
- def finish_editing_door_on_object(self, obj: bpy.types.Object) -> None:
- element = tool.Ifc.get_entity(obj)
- assert element
- if not tool.Blender.Modifier.is_door(element):
- return
- props = tool.Model.get_door_props(obj)
-
- door_data = props.get_general_kwargs(convert_to_project_units=True)
- lining_props = props.get_lining_kwargs(convert_to_project_units=True)
- panel_props = props.get_panel_kwargs(convert_to_project_units=True)
-
- door_data["lining_properties"] = lining_props
- door_data["panel_properties"] = panel_props
-
- props.is_editing = False
-
- update_door_modifier_representation(obj)
- element_type = ifcopenshell.util.element.get_type(element)
- if element_type:
- tool.Model.mark_thumbnail_for_update(element_type)
-
- pset = tool.Pset.get_element_pset(element, "BBIM_Door")
- door_data = tool.Ifc.get().createIfcText(json.dumps(door_data, default=list))
- ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": door_data})
-
- def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
- for obj in tool.Blender.get_selected_objects():
- self.finish_editing_door_on_object(obj)
- return {"FINISHED"}
+ def _execute(self, context: bpy.types.Context) -> set[str]:
+ return self._finish_targets(context)
-class EnableEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
+class EnableEditingDoor(_DoorEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_door"
bl_label = "Enable Editing Door on Selected Objects"
bl_description = "Enter edit mode to modify door parameters interactively"
bl_options = {"REGISTER", "UNDO"}
- def edit_door_on_obj(self, obj: bpy.types.Object) -> None:
- element = tool.Ifc.get_entity(obj)
- assert element
- if not tool.Blender.Modifier.is_door(element):
- return
- props = tool.Model.get_door_props(obj)
- data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Door", "Data"))
- data.update(data.pop("lining_properties"))
- data.update(data.pop("panel_properties"))
- data.update(tool.Model.get_constituents_props_data(element))
-
- # required since we could load pset from .ifc and BIMDoorProperties won't be set
- props.set_props_kwargs_from_ifc_data(data)
- props.is_editing = True
-
- def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
- for obj in tool.Blender.get_selected_objects():
- self.edit_door_on_obj(obj)
- return {"FINISHED"}
+ def _execute(self, context: bpy.types.Context) -> set[str]:
+ return self._enable_targets(context)
class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator):
@@ -939,7 +895,7 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
def update_swing_gizmos(self, mw: Matrix, props: "BIMDoorProperties") -> None:
"""Update swing gizmo position and color based on editing state."""
- prefs = tool.Blender.get_addon_preferences()
+ prefs = self.get_addon_prefs()
door_gizmo_prefs = prefs.gizmos.door
door_type_visible = self.update_gizmo_visibility(
diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py
index ff6ea96130..77f4b8a9f2 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
@@ -193,6 +195,32 @@ def update_stair(self: "BIMStairProperties", context: bpy.types.Context) -> None
_get_updater("stair", "regenerate_stair_mesh")(obj)
+def update_wall(self: "BIMWallProperties", context: bpy.types.Context) -> None:
+ """Regenerate wall mesh preview when property changes. Does NOT touch IFC."""
+ obj = context.active_object
+ if obj and self.is_editing:
+ _get_updater("wall", "regenerate_wall_mesh_from_props")(obj)
+
+
+def update_wall_offset_baseline(self: "BIMWallProperties", context: bpy.types.Context) -> None:
+ """Recompute the preview-only ``offset`` when the draft baseline cycles. Does not touch IFC.
+
+ ``offset`` itself has no ``update`` callback on purpose — adding one would make
+ every baseline cycle rebuild the bmesh twice (once via offset's callback, once
+ explicitly below)."""
+ obj = context.active_object
+ if not (obj and self.is_editing):
+ return
+ t = self.thickness
+ if self.desired_offset_baseline == "CENTER":
+ self.offset = -t / 2
+ elif self.desired_offset_baseline == "INTERIOR":
+ self.offset = -t
+ else: # EXTERIOR
+ self.offset = 0.0
+ _get_updater("wall", "regenerate_wall_mesh_from_props")(obj)
+
+
def update_railing(self: "BIMRailingProperties", context: bpy.types.Context) -> None:
"""Regenerate railing mesh when property changes."""
if self.is_editing:
@@ -1631,6 +1659,118 @@ class BIMRoofProperties(PropertyGroup):
setattr(target_props, prop_name, prop_value)
+class BIMWallProperties(PropertyGroup):
+ """Transient draft state for parametric wall gizmo editing.
+
+ Populated from IFC on `bim.enable_editing_wall`, mutated by gizmo drags during edit
+ (preview only — no IFC writes), and either committed by `bim.finish_editing_wall`
+ or discarded by `bim.cancel_editing_wall`.
+
+ The `snap_*` fields are the values captured on enable; `finish_editing_wall` compares
+ current vs snap to skip unchanged params and guarantee a no-op session leaves the
+ IFC file byte-identical.
+ """
+
+ is_editing: bpy.props.BoolProperty(
+ default=False,
+ description="True while wall parametric edit mode is active.",
+ )
+ mesh_dirty: bpy.props.BoolProperty(
+ default=False,
+ options={"HIDDEN", "SKIP_SAVE"},
+ description=(
+ "True while the visible mesh is the preview box; cleared once the real "
+ "IFC-derived geometry is restored (on commit or cancel)."
+ ),
+ )
+ length: bpy.props.FloatProperty(
+ name="Length",
+ default=1.0,
+ min=0.01,
+ subtype="DISTANCE",
+ update=update_wall,
+ description="Wall length along its reference axis (preview value; committed on finish).",
+ )
+ height: bpy.props.FloatProperty(
+ name="Height",
+ default=3.0,
+ min=0.01,
+ subtype="DISTANCE",
+ update=update_wall,
+ description="Wall vertical height (preview value; committed on finish).",
+ )
+ x_angle: bpy.props.FloatProperty(
+ name="Slope (X Angle)",
+ default=0.0,
+ soft_min=-math.pi / 3,
+ soft_max=math.pi / 3,
+ subtype="ANGLE",
+ update=update_wall,
+ description="Slope angle: tilt of the wall's top face along +Y (preview value; committed on finish).",
+ )
+ thickness: bpy.props.FloatProperty(
+ name="Thickness",
+ default=0.2,
+ min=0.001,
+ subtype="DISTANCE",
+ description="Wall thickness captured from IFC at edit-enable; not gizmo-bound.",
+ )
+ offset: bpy.props.FloatProperty(
+ name="Offset",
+ default=0.0,
+ subtype="DISTANCE",
+ description="Layer-set offset captured from IFC at edit-enable; driven by desired_offset_baseline.",
+ )
+ desired_offset_baseline: bpy.props.EnumProperty(
+ items=[
+ ("EXTERIOR", "Exterior", "Reference axis at the exterior face"),
+ ("CENTER", "Center", "Reference axis at the wall centreline"),
+ ("INTERIOR", "Interior", "Reference axis at the interior face"),
+ ],
+ name="Desired Offset Baseline",
+ default="CENTER",
+ update=update_wall_offset_baseline,
+ description="Which face of the wall the reference axis aligns to (preview value; committed on finish).",
+ )
+ anchor_x: bpy.props.FloatProperty(
+ default=0.0,
+ subtype="DISTANCE",
+ description="Local-X of the wall's axis polyline start, so the preview box lands where the IFC mesh does.",
+ )
+
+ snap_length: bpy.props.FloatProperty(description="Snapshot of length at edit-enable; commit skips no-op writes.")
+ snap_height: bpy.props.FloatProperty(description="Snapshot of height at edit-enable; commit skips no-op writes.")
+ snap_thickness: bpy.props.FloatProperty(
+ description="Snapshot of thickness at edit-enable; commit skips no-op writes."
+ )
+ snap_offset: bpy.props.FloatProperty(description="Snapshot of offset at edit-enable; commit skips no-op writes.")
+ snap_x_angle: bpy.props.FloatProperty(
+ subtype="ANGLE",
+ description="Snapshot of x_angle at edit-enable; commit skips no-op writes.",
+ )
+ snap_offset_baseline: bpy.props.StringProperty(
+ default="",
+ description="Snapshot of desired_offset_baseline at edit-enable; commit skips no-op writes.",
+ )
+
+ if TYPE_CHECKING:
+ is_editing: bool
+ mesh_dirty: bool
+ length: float
+ height: float
+ x_angle: float
+ thickness: float
+ offset: float
+ desired_offset_baseline: Literal["EXTERIOR", "CENTER", "INTERIOR"]
+ anchor_x: float
+ snap_length: float
+ snap_height: float
+ snap_thickness: float
+ snap_offset: float
+ snap_x_angle: float
+ snap_offset_baseline: str
+
+
class SnapMousePoint(PropertyGroup):
x: bpy.props.FloatProperty(name="X")
y: bpy.props.FloatProperty(name="Y")
diff --git a/src/bonsai/bonsai/bim/module/model/railing.py b/src/bonsai/bonsai/bim/module/model/railing.py
index 7ca66d8dbc..641674b060 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
@@ -406,66 +407,65 @@ class CopyRailingParameters(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
-class EnableEditingRailing(bpy.types.Operator, tool.Ifc.Operator):
- bl_idname = "bim.enable_editing_railing"
- bl_label = "Enable Editing Railing"
- bl_options = {"REGISTER"}
+class _RailingEditMixin(PathPreservingEditMixin):
+ """Type-specific hooks for railing parametric-edit operators. Single-object
+ (active_object). ``path_data`` is preserved through the edit; the separate
+ ``Enable/Finish/CancelEditingRailingPath`` operators handle path editing."""
- def _execute(self, context):
- obj = context.active_object
- assert obj
- props = tool.Model.get_railing_props(obj)
- data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"]
+ pset_name = "BBIM_Railing"
+
+ @classmethod
+ def _is_element_type(cls, element):
+ return tool.Blender.Modifier.is_railing(element)
+
+ @classmethod
+ def _get_props(cls, obj: bpy.types.Object):
+ return tool.Model.get_railing_props(obj)
+
+ @classmethod
+ def _post_load_data(cls, data: dict) -> dict:
+ # BIMRailingProperties.path_data is a StringProperty holding JSON.
data["path_data"] = json.dumps(data["path_data"])
+ return data
- # required since we could load pset from .ifc and BIMRailingProperties won't be set
- props.set_props_kwargs_from_ifc_data(data)
+ @classmethod
+ def _update_pset(cls, element, data: dict) -> None:
+ update_bbim_railing_pset(element, data)
- props.is_editing = True
- return {"FINISHED"}
+ @classmethod
+ def _update_modifier_ifc_data(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
+ update_railing_modifier_ifc_data(context)
-
-class CancelEditingRailing(bpy.types.Operator, tool.Ifc.Operator):
- bl_idname = "bim.cancel_editing_railing"
- bl_label = "Cancel Editing Railing"
- bl_options = {"REGISTER"}
-
- def _execute(self, context):
- obj = context.active_object
- assert obj
- data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"]
- props = tool.Model.get_railing_props(obj)
-
- # restore previous settings since editing was canceled
- props.set_props_kwargs_from_ifc_data(data)
+ @classmethod
+ def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_railing_modifier_bmesh(context)
- props.is_editing = False
- return {"FINISHED"}
-
-class FinishEditingRailing(bpy.types.Operator, tool.Ifc.Operator):
- bl_idname = "bim.finish_editing_railing"
- bl_label = "Finish Editing Railing"
- bl_options = {"REGISTER"}
+class EnableEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator):
+ bl_idname = "bim.enable_editing_railing"
+ bl_label = "Enable Editing Railing"
+ bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
- obj = context.active_object
- assert obj
- element = tool.Ifc.get_entity(obj)
- assert element
- props = tool.Model.get_railing_props(obj)
+ return self._enable_targets(context)
- pset_data = tool.Model.get_modeling_bbim_pset_data(bpy.context.active_object, "BBIM_Railing")
- path_data = pset_data["data_dict"]["path_data"]
- railing_data = props.get_general_kwargs(convert_to_project_units=True)
- railing_data["path_data"] = path_data
- props.is_editing = False
+class CancelEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator):
+ bl_idname = "bim.cancel_editing_railing"
+ bl_label = "Cancel Editing Railing"
+ bl_options = {"REGISTER", "UNDO"}
- update_bbim_railing_pset(element, railing_data)
- update_railing_modifier_ifc_data(context)
- return {"FINISHED"}
+ def _execute(self, context):
+ return self._cancel_targets(context)
+
+
+class FinishEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator):
+ bl_idname = "bim.finish_editing_railing"
+ bl_label = "Finish Editing Railing"
+ bl_options = {"REGISTER", "UNDO"}
+
+ def _execute(self, context):
+ return self._finish_targets(context)
class FlipRailingPathOrder(bpy.types.Operator, tool.Ifc.Operator):
diff --git a/src/bonsai/bonsai/bim/module/model/roof.py b/src/bonsai/bonsai/bim/module/model/roof.py
index e1f7903299..b949827ed2 100644
--- a/src/bonsai/bonsai/bim/module/model/roof.py
+++ b/src/bonsai/bonsai/bim/module/model/roof.py
@@ -34,6 +34,7 @@ import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.model.data import RoofData, refresh
from bonsai.bim.module.model.decorator import ProfileDecorator
+from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin
# reference:
# https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRoof.htm
@@ -608,61 +609,59 @@ class AddRoof(bpy.types.Operator, tool.Ifc.Operator):
tool.Model.add_body_representation(obj)
-class EnableEditingRoof(bpy.types.Operator, tool.Ifc.Operator):
- bl_idname = "bim.enable_editing_roof"
- bl_label = "Enable Editing Roof"
- bl_options = {"REGISTER"}
+class _RoofEditMixin(PathPreservingEditMixin):
+ """Type-specific hooks for roof parametric-edit operators. Single-object
+ (active_object). ``path_data`` is preserved through the edit; the separate
+ ``Enable/Finish/CancelEditingRoofPath`` operators handle path editing."""
- def _execute(self, context):
- obj = context.active_object
- assert obj
- props = tool.Model.get_roof_props(obj)
- data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")["data_dict"]
- # required since we could load pset from .ifc and BIMRoofProperties won't be set
- props.set_props_kwargs_from_ifc_data(data)
- props.is_editing = True
- return {"FINISHED"}
+ pset_name = "BBIM_Roof"
+ @classmethod
+ def _is_element_type(cls, element):
+ return tool.Blender.Modifier.is_roof(element)
-class CancelEditingRoof(bpy.types.Operator, tool.Ifc.Operator):
- bl_idname = "bim.cancel_editing_roof"
- bl_label = "Cancel Editing Roof"
- bl_options = {"REGISTER"}
+ @classmethod
+ def _get_props(cls, obj: bpy.types.Object):
+ return tool.Model.get_roof_props(obj)
- def _execute(self, context):
- obj = context.active_object
- assert obj
- data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")["data_dict"]
- props = tool.Model.get_roof_props(obj)
+ @classmethod
+ def _update_pset(cls, element, data: dict) -> None:
+ update_bbim_roof_pset(element, data)
- # restore previous settings since editing was canceled
- props.set_props_kwargs_from_ifc_data(data)
+ @classmethod
+ def _update_modifier_ifc_data(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
+ update_roof_modifier_ifc_data(context)
+
+ @classmethod
+ def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_roof_modifier_bmesh(obj)
- props.is_editing = False
- return {"FINISHED"}
-
-class FinishEditingRoof(bpy.types.Operator, tool.Ifc.Operator):
- bl_idname = "bim.finish_editing_roof"
- bl_label = "Finish Editing Roof"
- bl_options = {"REGISTER"}
+class EnableEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
+ bl_idname = "bim.enable_editing_roof"
+ bl_label = "Enable Editing Roof"
+ bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
- obj = context.active_object
- element = tool.Ifc.get_entity(obj)
- props = tool.Model.get_roof_props(obj)
+ return self._enable_targets(context)
- pset_data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")
- path_data = pset_data["data_dict"]["path_data"]
- roof_data = props.get_general_kwargs(convert_to_project_units=True)
- roof_data["path_data"] = path_data
- props.is_editing = False
+class CancelEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
+ bl_idname = "bim.cancel_editing_roof"
+ bl_label = "Cancel Editing Roof"
+ bl_options = {"REGISTER", "UNDO"}
- update_bbim_roof_pset(element, roof_data)
- update_roof_modifier_ifc_data(context)
- return {"FINISHED"}
+ def _execute(self, context):
+ return self._cancel_targets(context)
+
+
+class FinishEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
+ bl_idname = "bim.finish_editing_roof"
+ bl_label = "Finish Editing Roof"
+ bl_options = {"REGISTER", "UNDO"}
+
+ def _execute(self, context):
+ return self._finish_targets(context)
class EnableEditingRoofPath(bpy.types.Operator, tool.Ifc.Operator):
diff --git a/src/bonsai/bonsai/bim/module/model/stair.py b/src/bonsai/bonsai/bim/module/model/stair.py
index 87152c645b..0834263552 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
@@ -262,7 +264,6 @@ class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator):
# Use the special method that includes custom_tread_lock for IFC storage
data = props.get_props_kwargs_for_ifc_export(convert_to_project_units=True)
- props.is_editing = False
regenerate_stair_mesh(obj)
tool.Model.add_body_representation(obj)
@@ -272,6 +273,7 @@ class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator):
# update IfcStairFlight properties
update_ifc_stair_props(obj)
+ props.is_editing = False
return {"FINISHED"}
@@ -608,29 +610,23 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
"VIEW3D_GT_minus", self.COLOR_RED, "bim.adjust_stair_treads", increment=-1
)
- def _refresh_element_specific(self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties") -> None:
- """Update stair-specific lock and tread count gizmos."""
- billboard_rot = gizmo.get_billboard_rotation(context)
- self.update_lock_gizmo(mw, props, billboard_rot)
+ def _refresh_element_specific(
+ self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
+ ) -> None:
+ """Update stair-specific lock and tread count gizmos. Lock positioning is
+ handled per-frame in the dimension-positioning hook."""
+ self.update_lock_gizmo(props)
self.update_tread_lock_gizmo(props)
self.update_tread_count_gizmos(props)
- def update_lock_gizmo(self, mw: Matrix, props: "BIMStairProperties", billboard_rot: Matrix) -> None:
- """Update lock gizmo visibility, color, and position."""
+ def update_lock_gizmo(self, props: "BIMStairProperties") -> None:
+ """Update lock gizmo color and visibility. Positioning is handled
+ per-frame by the dimension-positioning hook."""
gizmo_prefs = self.get_gizmo_prefs()
if not self.update_gizmo_visibility(self.lock_gizmo, props.is_editing, gizmo_prefs.lock):
- return # Hidden, skip positioning
-
+ return # Hidden, skip color update
self.lock_gizmo.color = self.COLOR_RED if props.total_length_lock else self.COLOR_GREEN
- total_run = props.get_total_run()
- local_transform = (
- Matrix.Translation(Vector((total_run + self.ICON_Z_OFFSET, -self.GIZMO_OFFSET, -self.GIZMO_OFFSET)))
- @ billboard_rot
- @ Matrix.Scale(self.EDITING_ICON_SCALE, 4)
- )
- self.lock_gizmo.matrix_basis = mw @ local_transform
-
def update_tread_lock_gizmo(self, props: "BIMStairProperties") -> None:
"""Update visibility of tread lock gizmo. Positioning is handled in _update_editing_icon_positions."""
if not hasattr(self, "tread_lock_gizmo"):
@@ -650,11 +646,11 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
)
def _update_dimension_gizmo_positions(
- self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties"
+ self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
) -> None:
"""Update dimension gizmo positions based on camera view direction."""
- viewing_from_negative_y, viewing_from_negative_x = self.get_local_view_direction(context, mw)
- billboard_rot = gizmo.get_billboard_rotation(context)
+ viewing_from_negative_y, viewing_from_negative_x = self._frame_view_dir
+ billboard_rot = self._frame_billboard_rot
total_run = props.get_total_run()
riser_height = props.get_riser_height()
diff --git a/src/bonsai/bonsai/bim/module/model/ui.py b/src/bonsai/bonsai/bim/module/model/ui.py
index eef71ed433..7775bdd67b 100644
--- a/src/bonsai/bonsai/bim/module/model/ui.py
+++ b/src/bonsai/bonsai/bim/module/model/ui.py
@@ -338,6 +338,36 @@ class BIM_PT_stair(bpy.types.Panel):
row.operator("bim.add_stair", icon="ADD", text="")
+class BIM_PT_wall(bpy.types.Panel):
+ bl_label = "Wall"
+ bl_idname = "BIM_PT_wall"
+ bl_space_type = "PROPERTIES"
+ bl_region_type = "WINDOW"
+ bl_context = "scene"
+ bl_options = {"DEFAULT_CLOSED"}
+ bl_parent_id = "BIM_PT_tab_parametric_geometry"
+
+ @classmethod
+ def poll(cls, context):
+ obj = context.active_object
+ if not obj:
+ return False
+ element = tool.Ifc.get_entity(obj)
+ return bool(element) and tool.Blender.Modifier.is_wall(element)
+
+ def draw(self, context):
+ obj = context.active_object
+ if obj is None:
+ return
+ props = tool.Model.get_wall_props(obj)
+ row = self.layout.row(align=True)
+ if props.is_editing:
+ row.operator("bim.finish_editing_wall", icon="CHECKMARK", text="Finish Editing")
+ row.operator("bim.cancel_editing_wall", icon="CANCEL", text="")
+ else:
+ row.operator("bim.enable_editing_wall", icon="GREASEPENCIL", text="Edit Wall")
+
+
class BIM_PT_sverchok(bpy.types.Panel):
bl_label = "Sverchok"
bl_idname = "BIM_PT_sverchok"
diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py
index f64adfe7ab..503a914041 100644
--- a/src/bonsai/bonsai/bim/module/model/wall.py
+++ b/src/bonsai/bonsai/bim/module/model/wall.py
@@ -16,13 +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.
+#
# pyright: reportUnnecessaryTypeIgnoreComment=error
import copy
import math
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, Union, get_args
+import bmesh
import bpy
import ifcopenshell
import ifcopenshell.api.feature
@@ -46,9 +49,121 @@ import bonsai.core.model as core
import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.ifc import IfcStore
+from bonsai.bim.module.drawing import gizmos as gizmo
+from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from bonsai.bim.module.model.decorator import PolylineDecorator, ProductDecorator
from bonsai.bim.module.model.polyline import PolylineOperator
+if TYPE_CHECKING:
+ from bonsai.bim.module.model.prop import BIMWallProperties
+
+
+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)
+ 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 _validate_wall_for_parametric_edit(obj: bpy.types.Object) -> str | None:
+ """Return ``None`` if the wall is parametrically editable, else a user-facing reason
+ string explaining what's missing. Reports the *specific* gap rather than a generic
+ 'not parametric' so the user knows whether to fix the material layer set, swap the
+ body representation, or pick a different object."""
+ 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 = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
+ 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
+
+
+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 ``_validate_wall_for_parametric_edit`` first — this function assumes the
+ wall has a LAYER2 usage and an extruded MODEL_VIEW body."""
+ geom = _read_wall_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
+
class UnjoinWalls(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.unjoin_walls"
@@ -64,6 +179,7 @@ class UnjoinWalls(bpy.types.Operator, tool.Ifc.Operator):
return True
def _execute(self, context):
+ _commit_pending_wall_edits_for_selection(context)
core.unjoin_walls(tool.Ifc, tool.Blender, tool.Geometry, DumbWallJoiner(), tool.Model)
@@ -73,20 +189,50 @@ class ExtendWallsToUnderside(bpy.types.Operator, tool.Ifc.Operator):
bl_description = "Extend and clip selected walls at the bottom faces of an object"
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):
- slab = None
+ # Match the sibling ops (UnjoinWalls / MergeWall / ExtendWallsToWall): if any
+ # of the selected walls has an in-progress parametric draft, commit it before
+ # extending, so the slab clip operates on the just-finalised IFC state.
+ _commit_pending_wall_edits_for_selection(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):
+ for obj in tool.Blender.get_selected_objects():
element = tool.Ifc.get_entity(obj)
- usage = tool.Model.get_usage_type(element) if element else None
- if element and usage == "LAYER2":
+ 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)
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 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(bpy.types.Operator, tool.Ifc.Operator):
@@ -96,6 +242,7 @@ class ExtendWallsToWall(bpy.types.Operator, tool.Ifc.Operator):
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
+ _commit_pending_wall_edits_for_selection(context)
target_obj = None
objs = []
if (
@@ -323,6 +470,7 @@ class SplitWall(bpy.types.Operator, tool.Ifc.Operator):
return True
def _execute(self, context):
+ _commit_pending_wall_edits_for_selection(context)
selected_objs = tool.Model.get_selected_mesh_objects()
for obj in selected_objs:
DumbWallJoiner().split(obj, context.scene.cursor.location)
@@ -350,6 +498,7 @@ class MergeWall(bpy.types.Operator, tool.Ifc.Operator):
return True
def _execute(self, context):
+ _commit_pending_wall_edits_for_selection(context)
active_obj = context.active_object
assert active_obj
selected_objs = tool.Model.get_selected_mesh_objects()
@@ -459,7 +608,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)
@@ -470,14 +619,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
@@ -1339,7 +1494,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
@@ -1404,3 +1561,1072 @@ 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 = _validate_wall_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)
+
+ 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.
+ # We render three state-specific baseline icons at that slot instead — see
+ # ``setup_element_specific_gizmos`` / ``_update_icon_row_extras``.
+ 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 = "get_wall_props"
+ gizmo_pref_name = "wall"
+
+ @classmethod
+ def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
+ return tool.Blender.Modifier.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),
+ )
+
+ # X offsets in the editing icon row, additive from ICON_VALIDATE_X (0.0).
+ # Matches the cadence used by the base class (0.0 / 0.5 / 0.87 = step ≈ 0.37).
+ # The baseline icons (EXT / CEN / INT) all share ICON_CYCLE_X — only one is
+ # ever visible at a time so they don't overlap.
+ ICON_ROTATE_X = 1.24
+
+ # Mapping from BIMWallProperties.desired_offset_baseline value to the
+ # attribute on `self` that holds the corresponding state icon.
+ _BASELINE_GIZMO_ATTRS: ClassVar[dict[str, str]] = {
+ "EXTERIOR": "offset_exterior_gizmo",
+ "CENTER": "offset_center_gizmo",
+ "INTERIOR": "offset_interior_gizmo",
+ }
+
+ 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.
+
+ Icon-row (always visible during edit mode, fixed position):
+
+ - ``offset_{exterior,center,interior}_gizmo`` — three state-specific icons,
+ only one visible at a time. Reflects ``props.desired_offset_baseline``.
+ Clicking any of them cycles the baseline (the operator is the same).
+ - ``rotate_gizmo`` — rotates the wall 90° around Z (Shift+R). Uses the
+ revolving-arrows icon now that the cycle slot is occupied by the
+ stateful baseline icons.
+ - ``toggle_openings_gizmo`` — toggles opening fill visibility (Alt+O).
+ """
+ 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,
+ )
+ # Three baseline-state icons — only one is visible at a time, picked by
+ # the current props.desired_offset_baseline. All point to the same cycle
+ # operator so clicking any of them advances the cycle.
+ for baseline, attr_name in self._BASELINE_GIZMO_ATTRS.items():
+ setattr(
+ self,
+ attr_name,
+ self._setup_icon_gizmo(
+ f"VIEW3D_GT_offset_{baseline.lower()}",
+ default_color,
+ "bim.cycle_wall_offset",
+ highlight_color,
+ ),
+ )
+ self.rotate_gizmo = self._setup_icon_gizmo(
+ "VIEW3D_GT_cycle",
+ default_color,
+ "bim.rotate_wall_90",
+ highlight_color,
+ )
+ self.toggle_openings_gizmo = self._setup_icon_gizmo(
+ "VIEW3D_GT_add_opening",
+ default_color,
+ "bim.toggle_wall_openings",
+ highlight_color,
+ )
+
+ 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 _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, each at the Z its action would land at.
+
+ When two icons want the same Z (within ``CURSOR_STACK_OFFSET``), bump the
+ lower-priority one upward so both stay clickable. Priority low → high:
+ extend-X, extend-Z, split. Bumps cascade — bumping extend-Z up can in turn
+ collide with split, so extend-Z gets bumped further to clear it."""
+ if not hasattr(self, "split_gizmo"):
+ return
+ gizmo_prefs = self.get_gizmo_prefs()
+ all_gizmos = (self.extend_x_gizmo, self.extend_z_gizmo, self.split_gizmo)
+ if not props.is_editing:
+ for gz in all_gizmos:
+ gz.hide = True
+ return
+ cursor_world = context.scene.cursor.location
+ cursor_local = mw.inverted() @ cursor_world
+ in_range = props.anchor_x < cursor_local.x < props.anchor_x + props.length
+ billboard_rot = self._frame_billboard_rot
+
+ # Candidates ordered by priority (lowest first). Each is (gizmo, local_z).
+ # The local X and Y are common: at the cursor's projected X on the axis.
+ # Only "active" gizmos (enabled + applicable) participate in placement.
+ candidates: list[tuple[bpy.types.Gizmo, float]] = []
+ if gizmo_prefs.extend:
+ candidates.append((self.extend_x_gizmo, 0.0))
+ if gizmo_prefs.extend_height:
+ candidates.append((self.extend_z_gizmo, cursor_local.z))
+ if in_range and gizmo_prefs.scissors:
+ candidates.append((self.split_gizmo, props.height))
+
+ # 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:
+ # Bump up to clear the previous gizmo's slot.
+ final_z = prev_z + self.CURSOR_STACK_OFFSET
+ resolved.append((gz, final_z))
+
+ for gz in all_gizmos:
+ gz.hide = True
+ for gz, local_z in resolved:
+ gz.hide = self.is_gizmo_hidden_by_modal(gz)
+ world_pos = mw @ Vector((cursor_local.x, 0.0, local_z))
+ gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot)
+
+ def _update_icon_row_extras(self, context: bpy.types.Context, mw: Matrix, props: "BIMWallProperties") -> None:
+ """Position the wall-specific icons in the icon row.
+
+ Edit-mode icons (visible only when ``props.is_editing``):
+
+ - Three baseline icons (Exterior / Centreline / Interior) share the cycle
+ slot — only the one matching ``props.desired_offset_baseline`` shows.
+ - Rotate-90 icon at ``ICON_ROTATE_X``.
+
+ Non-edit-mode icons (visible alongside the pen icon, hidden during edit):
+
+ - Toggle-openings icon next to the pen. Lives outside edit mode because
+ opening visibility is a viewport-display concern, not a wall-edit action.
+
+ Calls ``billboarded_at`` directly rather than routing through
+ ``set_icon_gizmo_position`` because the icon row has wall-specific
+ visibility/state branching (baseline-indicator selection, edit-mode
+ toggle for opening-visibility) that the helper does not model."""
+ if not hasattr(self, "rotate_gizmo"):
+ return
+ gizmo_prefs = self.get_gizmo_prefs()
+ 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
+
+ # --- Edit-mode icons (baseline indicator + rotate-90) ---
+ if props.is_editing:
+ # Stateful baseline indicator at the cycle slot. Show exactly one of the
+ # three icons (the one matching the current baseline), hide the others.
+ for baseline, attr in self._BASELINE_GIZMO_ATTRS.items():
+ gz = getattr(self, attr)
+ if gizmo_prefs.cycle and baseline == props.desired_offset_baseline:
+ gz.hide = self.is_gizmo_hidden_by_modal(gz)
+ world_pos = mw @ Vector((self.ICON_VALIDATE_X + self.ICON_CYCLE_X, icon_y, icon_z))
+ gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot)
+ else:
+ gz.hide = True
+ if gizmo_prefs.rotate:
+ self.rotate_gizmo.hide = self.is_gizmo_hidden_by_modal(self.rotate_gizmo)
+ world_pos = mw @ Vector((self.ICON_VALIDATE_X + self.ICON_ROTATE_X, icon_y, icon_z))
+ # VIEW3D_GT_cycle is authored for the base class's 0.30 scale; at 0.5
+ # it looks roughly 2x too big next to the validate / cancel icons.
+ self.rotate_gizmo.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot, scale=0.30)
+ else:
+ self.rotate_gizmo.hide = True
+ else:
+ for attr in self._BASELINE_GIZMO_ATTRS.values():
+ getattr(self, attr).hide = True
+ self.rotate_gizmo.hide = True
+
+ # --- Non-edit-mode icons (toggle openings) ---
+ # Sits at the slot the cancel icon occupies during editing — that way the
+ # pen + openings pair is compact and visually grouped.
+ if not props.is_editing and gizmo_prefs.toggle_openings:
+ self.toggle_openings_gizmo.hide = self.is_gizmo_hidden_by_modal(self.toggle_openings_gizmo)
+ world_pos = mw @ Vector((self.ICON_VALIDATE_X + self.ICON_CANCEL_X, icon_y, icon_z))
+ self.toggle_openings_gizmo.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot)
+ else:
+ self.toggle_openings_gizmo.hide = True
+
+
+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 _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`.
+
+ Kept as a named helper because every multi-wall operator (split / join / merge /
+ unjoin / extend-to-wall …) calls it at the top of ``_execute``; centralising the
+ ``names=("wall",)`` filter here means the registry name is touched in 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.
+ if _commit_active_wall_edit_if_any(context) is None:
+ return {"CANCELLED"}
+ bpy.ops.bim.split_wall()
+ 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,
+ )
+ 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)
+ 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"}
+
+
+class ToggleWallOpenings(bpy.types.Operator, tool.Ifc.Operator):
+ bl_idname = "bim.toggle_wall_openings"
+ bl_label = "Toggle Openings"
+ bl_description = "Show or hide opening fills (doors and windows) in the viewport"
+ 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 wall geometry — don't commit the
+ # active wall edit; the user can keep editing the wall.
+ if tool.Model.get_model_props().openings:
+ bpy.ops.bim.edit_openings(apply_all=True)
+ else:
+ bpy.ops.bim.show_openings()
+ return {"FINISHED"}
+
+
+def _read_wall_geometry(obj: bpy.types.Object) -> dict | None:
+ """Live-read wall geometry from IFC. Returns ``None`` if the wall is not a LAYER2 extruded wall."""
+ element = tool.Ifc.get_entity(obj)
+ if not element or not tool.Blender.Modifier.is_wall(element):
+ return None
+ representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
+ 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": core.vertical_height_from_extrusion_depth(extrusion.Depth * unit_scale, x_angle),
+ "x_angle": x_angle,
+ "thickness": layer_params["thickness"],
+ "offset": layer_params["offset"],
+ }
+
+
+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
+ ``_read_wall_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.position_gizmos(context)
+
+
+def _get_wall_geom_cached(group: "bpy.types.GizmoGroup", obj: bpy.types.Object) -> dict | None:
+ """Per-gizmo-group memoised ``_read_wall_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] = _read_wall_geometry(obj)
+ 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])),
+ )
+ )
+
+
+class GizmoWallAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
+ """Activates when a wall (active) and one non-wall blender object are co-selected.
+
+ Renders a single icon above the wall at the wall-local X corresponding to the other
+ object's projected origin. Clicking dispatches `bim.add_opening`, which lets the
+ existing FilledOpeningGenerator decide how the opening is applied.
+
+ Per-frame positioning via `BillboardingGizmoGroupMixin` ensures the icon
+ keeps facing the camera as the viewport is orbited."""
+
+ bl_idname = "OBJECT_GGT_bim_wall_add_opening"
+ bl_label = "Wall 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:
+ prefs = tool.Blender.get_addon_preferences()
+ if not prefs.gizmos.draw_gizmos_in_3d_viewport:
+ 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
+ element = tool.Ifc.get_entity(active)
+ if not element or not tool.Blender.Modifier.is_wall(element):
+ return False
+ other = next(o for o in selected if o is not active)
+ # If the other object is also a wall, the wall-join gizmo handles it instead.
+ other_element = tool.Ifc.get_entity(other)
+ if other_element and tool.Blender.Modifier.is_wall(other_element):
+ return False
+ return True
+
+ def setup(self, context: bpy.types.Context) -> None:
+ prefs = tool.Blender.get_addon_preferences()
+ default_color = prefs.decorations_colour[:3]
+ highlight_color = prefs.decorator_color_selected[:3]
+ 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:
+ wall_obj = context.active_object
+ if not wall_obj:
+ return
+ selected = tool.Blender.get_selected_objects()
+ other = next((o for o in selected if o is not wall_obj), None)
+ if not other:
+ return
+ geom = _get_wall_geom_cached(self, wall_obj)
+ if not geom:
+ return
+ 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"]))
+ # Place the icon on the camera-facing side of the wall, like the pen icon
+ # does for parametric edits — orbit the camera past the wall and the icon
+ # jumps to the visible face instead of being stranded behind it.
+ icon_y = _wall_camera_facing_icon_y(context, mw, geom)
+ icon_z = geom["height"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET
+ world_pos = mw @ Vector((local_x, icon_y, icon_z))
+ self.add_opening_icon.matrix_basis = gizmo.billboarded_at(world_pos, gizmo.get_billboard_rotation(context))
+
+
+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:
+ prefs = tool.Blender.get_addon_preferences()
+ if not prefs.gizmos.draw_gizmos_in_3d_viewport:
+ 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:
+ prefs = tool.Blender.get_addon_preferences()
+ default_color = prefs.decorations_colour[:3]
+ highlight_color = prefs.decorator_color_selected[:3]
+ 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
+ self.extend_vertical_icon.matrix_basis = gizmo.billboarded_at(world_pos, gizmo.get_billboard_rotation(context))
+
+
+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
+ # The intersection must be within this many *wall-lengths* of the NEAREST endpoint
+ # of each wall. This filters out the case where two walls are offset from world
+ # origin and their extrapolated axes happen to cross at a point that isn't near
+ # either wall's actual endpoints (which previously caused the icon to land at
+ # world origin for walls whose axes coincidentally converged there).
+ MAX_DISTANCE_TO_ENDPOINT_FACTOR = 0.75
+ # 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:
+ prefs = tool.Blender.get_addon_preferences()
+ if not prefs.gizmos.draw_gizmos_in_3d_viewport:
+ 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.Blender.Modifier.is_wall(element):
+ return False
+ return True
+
+ def setup(self, context: bpy.types.Context) -> None:
+ prefs = tool.Blender.get_addon_preferences()
+ default_color = prefs.decorations_colour[:3]
+ highlight_color = prefs.decorator_color_selected[:3]
+ 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")
+ self.join_icon = self.setup_icon_gizmo(
+ "VIEW3D_GT_merge", default_color, highlight_color, "bim.join_walls_intersection"
+ )
+ self.extend_to_wall_icon = self.setup_icon_gizmo(
+ "VIEW3D_GT_extend", default_color, highlight_color, "bim.extend_walls_to_wall"
+ )
+
+ def _all_icons(self) -> tuple[bpy.types.Gizmo, ...]:
+ return (self.unjoin_icon, self.merge_icon, self.join_icon, self.extend_to_wall_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)
+
+ # State 1: walls are already joined → show Unjoin only, at the shared
+ # corner's floor Z (no visibility lift — user expects the icon to sit
+ # exactly at the corner, not floating above it).
+ if _are_walls_joined(elem_a, elem_b):
+ corner = _collinear_boundary_world(seg_a, seg_b)
+ self.unjoin_icon.matrix_basis = gizmo.billboarded_at(corner, billboard_rot)
+ self.unjoin_icon.hide = False
+ 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, at floor Z (no visibility lift).
+ if _are_walls_collinear(seg_a, seg_b, self.PARALLEL_DOT_THRESHOLD, self.COLLINEAR_LINE_TOLERANCE):
+ boundary = _collinear_boundary_world(seg_a, seg_b)
+ 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
+ return
+
+ # State 3: non-parallel walls whose axes meet near each wall's endpoint
+ # → show Join at the floor + Extend-to-Wall at the active wall's top.
+ intersection_tuple = 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)
+ len_a = (seg_a[1] - seg_a[0]).length
+ len_b = (seg_b[1] - seg_b[0]).length
+ near_a = min((intersection - seg_a[0]).length, (intersection - seg_a[1]).length)
+ near_b = min((intersection - seg_b[0]).length, (intersection - seg_b[1]).length)
+ if (
+ near_a > len_a * self.MAX_DISTANCE_TO_ENDPOINT_FACTOR
+ or near_b > len_b * self.MAX_DISTANCE_TO_ENDPOINT_FACTOR
+ ):
+ self._hide_all()
+ return
+
+ # Join sits on the floor (lowest endpoint Z across both wall axes), exactly
+ # where the corner meets the ground — no visibility lift.
+ floor_z = min(seg_a[0].z, seg_a[1].z, seg_b[0].z, seg_b[1].z)
+ join_world = Vector((intersection.x, intersection.y, floor_z))
+ self.join_icon.matrix_basis = gizmo.billboarded_at(join_world, billboard_rot)
+ self.join_icon.hide = False
+
+ # Extend-to-Wall sits at the active wall's top, same XY as the join icon —
+ # the Z gap is what differentiates "join at corner" from "extend into other".
+ active = context.active_object if context.active_object in selected else None
+ geom = _read_wall_geometry(active) if active else None
+ if geom is None:
+ self.extend_to_wall_icon.hide = True
+ else:
+ active_top_z = active.matrix_world.translation.z + geom["height"]
+ extend_world = Vector((intersection.x, intersection.y, active_top_z))
+ self.extend_to_wall_icon.matrix_basis = gizmo.billboarded_at(extend_world, billboard_rot)
+ self.extend_to_wall_icon.hide = False
+
+ self.unjoin_icon.hide = True
+ self.merge_icon.hide = True
+
+
+class JoinWallsIntersection(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
+ return True
+
+ def _execute(self, context: bpy.types.Context) -> set[str]:
+ _commit_pending_wall_edits_for_selection(context)
+ 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"}
+ return {"FINISHED"}
diff --git a/src/bonsai/bonsai/bim/module/model/window.py b/src/bonsai/bonsai/bim/module/model/window.py
index 30e8d767b5..2432549661 100644
--- a/src/bonsai/bonsai/bim/module/model/window.py
+++ b/src/bonsai/bonsai/bim/module/model/window.py
@@ -39,6 +39,7 @@ import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
+from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin
if TYPE_CHECKING:
from bonsai.bim.module.model.prop import BIMWindowProperties
@@ -482,90 +483,53 @@ class AddWindow(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
-class CancelEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
+class _WindowEditMixin(FeatureModifierEditMixin):
+ """Type-specific hooks for window parametric-edit operators. Single-object
+ by design (window edits target the active object only)."""
+
+ pset_name = "BBIM_Window"
+
+ @classmethod
+ def _is_element_type(cls, element):
+ return tool.Blender.Modifier.is_window(element)
+
+ @classmethod
+ def _get_props(cls, obj: bpy.types.Object):
+ return tool.Model.get_window_props(obj)
+
+ @classmethod
+ def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
+ update_window_modifier_representation(context)
+
+
+class CancelEditingWindow(_WindowEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_window"
bl_label = "Cancel Editing Window"
bl_description = "Cancel editing and revert window parameters to their previous values"
- bl_options = {"REGISTER"}
+ bl_options = {"REGISTER", "UNDO"}
def _execute(self, context: bpy.types.Context) -> set[str]:
- obj = context.active_object
- assert obj
- element = tool.Ifc.get_entity(obj)
- assert element
- data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Window", "Data"))
- data.update(data.pop("lining_properties"))
- data.update(data.pop("panel_properties"))
- props = tool.Model.get_window_props(obj)
- props.set_props_kwargs_from_ifc_data(data)
-
- body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
- bonsai.core.geometry.switch_representation(
- tool.Ifc,
- tool.Geometry,
- obj=obj,
- representation=body,
- )
-
- props.is_editing = False
- return {"FINISHED"}
+ return self._cancel_targets(context)
-class FinishEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
+class FinishEditingWindow(_WindowEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_window"
bl_label = "Finish Editing Window"
bl_description = "Apply changes and finish editing window parameters"
- bl_options = {"REGISTER"}
+ bl_options = {"REGISTER", "UNDO"}
def _execute(self, context: bpy.types.Context) -> set[str]:
- obj = context.active_object
- assert obj
- element = tool.Ifc.get_entity(obj)
- assert element
- props = tool.Model.get_window_props(obj)
-
- window_data = props.get_general_kwargs(convert_to_project_units=True)
- lining_props = props.get_lining_kwargs(convert_to_project_units=True)
- panel_props = props.get_panel_kwargs(convert_to_project_units=True)
-
- window_data["lining_properties"] = lining_props
- window_data["panel_properties"] = panel_props
-
- props.is_editing = False
-
- update_window_modifier_representation(context)
- element_type = ifcopenshell.util.element.get_type(element)
- if element_type:
- tool.Model.mark_thumbnail_for_update(element_type)
-
- pset = tool.Pset.get_element_pset(element, "BBIM_Window")
- window_data = tool.Ifc.get().createIfcText(json.dumps(window_data, default=list))
- ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": window_data})
- return {"FINISHED"}
+ return self._finish_targets(context)
-class EnableEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
+class EnableEditingWindow(_WindowEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_window"
bl_label = "Enable Editing Window"
bl_description = "Enter edit mode to modify window parameters interactively"
- bl_options = {"REGISTER"}
+ bl_options = {"REGISTER", "UNDO"}
def _execute(self, context: bpy.types.Context) -> set[str]:
- obj = context.active_object
- assert obj
- props = tool.Model.get_window_props(obj)
- element = tool.Ifc.get_entity(obj)
- assert element
- data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Window", "Data"))
- data.update(data.pop("lining_properties"))
- data.update(data.pop("panel_properties"))
- data.update(tool.Model.get_constituents_props_data(element))
-
- # required since we could load pset from .ifc and BIMWindowProperties won't be set
- props.set_props_kwargs_from_ifc_data(data)
-
- props.is_editing = True
- return {"FINISHED"}
+ return self._enable_targets(context)
class RemoveWindow(bpy.types.Operator, tool.Ifc.Operator):
diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py
index 828fc2c21f..2ad550eb9a 100644
--- a/src/bonsai/bonsai/bim/module/model/workspace.py
+++ b/src/bonsai/bonsai/bim/module/model/workspace.py
@@ -1300,9 +1300,15 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
bpy.ops.bim.generate_space()
return
if self.active_material_usage == "LAYER2":
- bpy.ops.bim.recalculate_wall()
+ if element and tool.Model.has_underside_connection(element):
+ bpy.ops.bim.regenerate_wall_to_underside()
+ else:
+ bpy.ops.bim.recalculate_wall()
elif self.active_material_usage == "LAYER3":
bpy.ops.bim.recalculate_slab()
+ wall_objs = tool.Model.get_connected_wall_objs(element)
+ if wall_objs:
+ core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, wall_objs)
elif tool.System.get_ports(element):
bpy.ops.bim.regenerate_distribution_element()
elif self.active_material_usage == "PROFILE":
diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py
index d38c4f3b68..284d427cf3 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
@@ -1903,11 +1905,11 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
self.use_relative_path = tool.Project.get_project_props().use_relative_project_path
props = tool.Blender.get_bim_props()
- if (filepath := props.ifc_file) and not self.should_save_as:
- self.filepath = str(tool.Blender.ensure_blender_path_is_abs(Path(filepath)))
- return self.execute(context)
-
- return ExportHelper.invoke(self, context, event)
+ filepath = props.ifc_file
+ if not filepath or self.should_save_as:
+ return ExportHelper.invoke(self, context, event)
+ self.filepath = str(tool.Blender.ensure_blender_path_is_abs(Path(filepath)))
+ return self.execute(context)
def check(self, context):
# ExportHelper is automatically adjusting suffix to `filename_ext`.
@@ -1933,6 +1935,16 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
return {"FINISHED"}
def _execute(self, context):
+ committed, failed_commits = tool.Parametric.commit_pending_edits()
+ # Suffix is appended to the IFC save-success report below so the auto-commit
+ # info isn't immediately overwritten by the success message in Blender's
+ # status bar (only the latest self.report({"INFO"}, ...) sticks).
+ commit_suffix = f" (auto-committed {committed} pending parametric edit(s))" if committed else ""
+ if failed_commits:
+ names = ", ".join(o.name for o in failed_commits)
+ msg = f"Auto-commit failed for {len(failed_commits)} object(s): {names}"
+ print(f"Bonsai: {msg} (their drafts are NOT saved to the IFC file).")
+ self.report({"ERROR"}, msg)
start = time.time()
logger = logging.getLogger("ExportIFC")
path_log = tool.Blender.get_data_dir_path("process.log")
@@ -2001,7 +2013,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
blendmetadata_path = output_file + suffix
self.report(
{"INFO"},
- f'IFC Project "{os.path.basename(output_file)}" And Metadata File Saved to: {os.path.basename(blendmetadata_path)}',
+ f'IFC Project "{os.path.basename(output_file)}" And Metadata File Saved to: {os.path.basename(blendmetadata_path)}{commit_suffix}',
)
except Exception as e:
self.report({"ERROR"}, f"Failed to save blend metadata file: {e}")
@@ -2011,7 +2023,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()
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/parametric_lifecycle.py b/src/bonsai/bonsai/bim/parametric_lifecycle.py
new file mode 100644
index 0000000000..94324afa06
--- /dev/null
+++ b/src/bonsai/bonsai/bim/parametric_lifecycle.py
@@ -0,0 +1,297 @@
+# 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 Enable / Finish / Cancel lifecycle mixins for parametric-edit operators.
+
+Two mixins fit the parametric-edit triads in ``bim/module/model/``:
+
+`FeatureModifierEditMixin`
+ Door, Window — BBIM_ pset with nested ``lining_properties`` /
+ ``panel_properties``; Finish calls ``update__modifier_representation``
+ via ``ifcopenshell.api.feature``; Cancel restores via ``switch_representation``.
+
+`PathPreservingEditMixin`
+ Railing, Roof — BBIM_ pset whose ``path_data`` is preserved through
+ edit (only general kwargs are user-editable); Finish calls
+ ``update__modifier_bmesh`` / ``update__modifier_ifc_data``;
+ Cancel re-reads the pset and rebuilds the bmesh preview.
+
+Stair and Wall stay standalone — their lifecycles diverge in ways that don't
+fit either mixin without optional escape hatches (Stair has a unique
+``update_ifc_stair_props`` post-Finish step + a separate ``get_props_kwargs_for_ifc_export``;
+Wall is validation-first, snapshot-driven, no preview regen in operators).
+
+This module sits separately from `bonsai.tool.Parametric` (the registry +
+auto-commit) because it imports ``bonsai.tool`` freely, while the registry
+itself must stay light — ``tool/blender.py`` consumes the registry at module load."""
+
+from __future__ import annotations
+
+import json
+from typing import TYPE_CHECKING, ClassVar
+
+import bpy
+import ifcopenshell.api.pset
+import ifcopenshell.util.element
+import ifcopenshell.util.representation
+
+import bonsai.core.geometry
+import bonsai.tool as tool
+
+if TYPE_CHECKING:
+ from ifcopenshell import entity_instance
+
+
+class _ParametricEditMixinBase:
+ """Common scaffolding for parametric edit-triad 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]``)
+
+ 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)
+
+
+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``.
+
+ Door's helper takes ``obj``; window's takes ``context``. The hook lets
+ each subclass forward to its existing helper without unifying signatures."""
+ raise NotImplementedError
+
+ @classmethod
+ def _enable_one(cls, obj: bpy.types.Object) -> None:
+ resolved = cls._resolve(obj)
+ if resolved is None:
+ return
+ element, props = resolved
+ 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)
+ 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, cls.pset_name)
+ data_text = tool.Ifc.get().createIfcText(json.dumps(data, default=list))
+ ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": data_text})
+ # 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
+ 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 = 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
+
+ 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 triad — 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
+ lets railing JSON-serialise ``path_data`` for the PropertyGroup
+ string 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
+ ``_update_modifier_bmesh`` (per-type bmesh preview) →
+ ``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.
+
+ Railing overrides to JSON-serialise ``path_data`` (its
+ BIMRailingProperties.path_data is a ``StringProperty`` holding JSON)."""
+ 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 _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
+ """Hook: per-type ``update__modifier_bmesh`` — rebuilds the
+ bmesh preview to match the current draft props (used by Cancel)."""
+ raise NotImplementedError
+
+ @classmethod
+ def _enable_one(cls, obj: bpy.types.Object) -> None:
+ resolved = cls._resolve(obj)
+ if resolved is None:
+ return
+ _element, props = resolved
+ 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)
+ path_data = pset_data["data_dict"]["path_data"]
+ data = props.get_general_kwargs(convert_to_project_units=True)
+ data["path_data"] = path_data
+ cls._update_pset(element, data)
+ cls._update_modifier_ifc_data(obj, context)
+ # 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
+ 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)
+ cls._update_modifier_bmesh(obj, context)
+ 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"}
diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py
index 97980d92dd..02934f701d 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
@@ -380,6 +382,76 @@ class GizmoPreferencesStair(bpy.types.PropertyGroup):
cycle: bool
+class GizmoPreferencesWall(bpy.types.PropertyGroup):
+ """Property group for wall gizmo visibility settings."""
+
+ length: BoolProperty(
+ name="Length",
+ default=True,
+ description="Show the length dimension gizmo along the wall axis.",
+ )
+ height: BoolProperty(
+ name="Height",
+ default=True,
+ description="Show the height dimension gizmo at the wall's start endpoint.",
+ )
+ height_end: BoolProperty(
+ name="Height (far end, walls > 5m)",
+ default=True,
+ description=(
+ "Show a second height gizmo at the wall's far end so long walls don't "
+ "require panning to reach the handle."
+ ),
+ )
+ x_angle: BoolProperty(
+ name="Slope",
+ default=True,
+ description="Show the slope gizmo at the wall top measuring horizontal displacement of the top face.",
+ )
+ cycle: BoolProperty(
+ name="Cycle Offset Baseline",
+ default=True,
+ description="Show the baseline-state icon (Exterior / Centreline / Interior) in the editing icon row.",
+ )
+ scissors: BoolProperty(
+ name="Split at cursor",
+ default=True,
+ description="Show the split icon at the 3D cursor when it lies within the wall's length range.",
+ )
+ extend: BoolProperty(
+ name="Extend length to cursor X",
+ default=True,
+ description="Show the extend-length icon at the 3D cursor's projected wall-axis X.",
+ )
+ extend_height: BoolProperty(
+ name="Extend height to cursor Z",
+ default=True,
+ description="Show the extend-height icon at the 3D cursor's Z, on the wall axis.",
+ )
+ rotate: BoolProperty(
+ name="Rotate 90°",
+ default=True,
+ description="Show the rotate-90 icon in the editing icon row (rotates the wall around its Z axis).",
+ )
+ toggle_openings: BoolProperty(
+ name="Toggle Openings",
+ default=True,
+ description="Show the toggle-openings icon next to the pen (toggles opening fill visibility in the viewport).",
+ )
+
+ if TYPE_CHECKING:
+ length: bool
+ height: bool
+ height_end: bool
+ x_angle: bool
+ cycle: bool
+ scissors: bool
+ extend: bool
+ extend_height: bool
+ rotate: bool
+ toggle_openings: bool
+
+
class GizmoPreferences(bpy.types.PropertyGroup):
"""Property group for all gizmo visibility settings."""
@@ -391,12 +463,14 @@ class GizmoPreferences(bpy.types.PropertyGroup):
door: bpy.props.PointerProperty(type=GizmoPreferencesDoor)
window: bpy.props.PointerProperty(type=GizmoPreferencesWindow)
stair: bpy.props.PointerProperty(type=GizmoPreferencesStair)
+ wall: bpy.props.PointerProperty(type=GizmoPreferencesWall)
if TYPE_CHECKING:
draw_gizmos_in_3d_viewport: bool
door: GizmoPreferencesDoor
window: GizmoPreferencesWindow
stair: GizmoPreferencesStair
+ wall: GizmoPreferencesWall
class DocPreferences(bpy.types.PropertyGroup):
@@ -849,49 +923,56 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Door", self.draw_door_gizmo_parameters)
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Window", self.draw_window_gizmo_parameters)
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Stair", self.draw_stair_gizmo_parameters)
+ bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Wall", self.draw_wall_gizmo_parameters)
+
+ def _draw_parametric_gizmo_parameters(
+ self,
+ layout: bpy.types.UILayout,
+ gizmo_pg: bpy.types.PropertyGroup,
+ dimension_gizmo_class: type,
+ special_gizmo_names: frozenset[str] = frozenset(),
+ ) -> None:
+ """Draw the per-element gizmo visibility toggles. Surfaces every annotation
+ on ``gizmo_pg`` that either maps to one of ``dimension_gizmo_class``'s
+ dimension gizmos or is named in ``special_gizmo_names`` (non-dimension icons
+ like baseline cycle, scissors, rotate, …)."""
+ visible_names = {p.attr_name for p in dimension_gizmo_class.dimension_gizmo_props} | special_gizmo_names
+ try:
+ annotations = gizmo_pg.__annotations__
+ except AttributeError:
+ annotations = type(gizmo_pg).__annotations__
+ for prop in annotations:
+ if prop in visible_names:
+ layout.prop(gizmo_pg, prop)
def draw_door_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
from bonsai.bim.module.model.door import GizmoDoorEdition
- door_gizmos = self.gizmos.door
- gizmo_prop_names = {p.attr_name for p in GizmoDoorEdition.dimension_gizmo_props}
- # Add special gizmos not in dimension_gizmo_props
- gizmo_prop_names.update(("swing_arc", "flip_arc"))
- try:
- annotations = door_gizmos.__annotations__
- except AttributeError:
- annotations = type(door_gizmos).__annotations__
- for prop in annotations:
- if prop in gizmo_prop_names:
- layout.prop(door_gizmos, prop)
+ self._draw_parametric_gizmo_parameters(
+ layout, self.gizmos.door, GizmoDoorEdition, frozenset({"swing_arc", "flip_arc"})
+ )
def draw_window_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
from bonsai.bim.module.model.window import GizmoWindowEdition
- window_gizmos = self.gizmos.window
- gizmo_prop_names = {p.attr_name for p in GizmoWindowEdition.dimension_gizmo_props}
- try:
- annotations = window_gizmos.__annotations__
- except AttributeError:
- annotations = type(window_gizmos).__annotations__
- for prop in annotations:
- if prop in gizmo_prop_names:
- layout.prop(window_gizmos, prop)
+ self._draw_parametric_gizmo_parameters(layout, self.gizmos.window, GizmoWindowEdition)
def draw_stair_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
from bonsai.bim.module.model.stair import GizmoStairEdition
- stair_gizmos = self.gizmos.stair
- gizmo_prop_names = {p.attr_name for p in GizmoStairEdition.dimension_gizmo_props}
- # Add special gizmos not in dimension_gizmo_props
- special_gizmo_names = {"lock", "plus", "minus", "cycle"}
- try:
- annotations = stair_gizmos.__annotations__
- except AttributeError:
- annotations = type(stair_gizmos).__annotations__
- for prop in annotations:
- if prop in gizmo_prop_names or prop in special_gizmo_names:
- layout.prop(stair_gizmos, prop)
+ self._draw_parametric_gizmo_parameters(
+ layout, self.gizmos.stair, GizmoStairEdition, frozenset({"lock", "plus", "minus", "cycle"})
+ )
+
+ def draw_wall_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
+ from bonsai.bim.module.model.wall import GizmoWallEdition
+
+ self._draw_parametric_gizmo_parameters(
+ layout,
+ self.gizmos.wall,
+ GizmoWallEdition,
+ frozenset({"cycle", "scissors", "extend", "extend_height", "rotate", "toggle_openings"}),
+ )
def draw_model_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
layout.prop(self, "occurrence_name_style")
diff --git a/src/bonsai/bonsai/core/model.py b/src/bonsai/bonsai/core/model.py
index cf42afa43c..345f77244f 100644
--- a/src/bonsai/bonsai/core/model.py
+++ b/src/bonsai/bonsai/core/model.py
@@ -15,9 +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.
from __future__ import annotations
+import math
from typing import TYPE_CHECKING, Literal, Optional
if TYPE_CHECKING:
@@ -140,24 +143,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:
- clip = model.get_slab_clipping_bmesh(slab_obj)
- if not clip:
- return
- 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):
@@ -174,3 +226,141 @@ class RequireAtLeastTwoElements(Exception):
class RequireLayeredElement(Exception):
pass
+
+
+# --- Wall geometry math (pure) ------------------------------------------------
+# Tuple in / tuple out so these helpers run under ``pytest test/core/`` without
+# ``bpy`` or ``mathutils``. Callers convert ``mathutils.Vector`` at the boundary.
+
+
+def baseline_from_offset(offset: float, thickness: float, tolerance: float = 0.001) -> str:
+ """Classify a numeric layer offset as EXTERIOR / CENTER / INTERIOR.
+
+ Mirrors the math in ``tool.Model.offset_wall`` for 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 — e.g. ``cos(2°) ≈ 0.9994`` treats walls within 2° of parallel as parallel)."""
+ 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 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). Drives the slope dimension gizmo's display value.
+
+ Inverse of :func:`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 vertical walls of effectively
+ zero height map cleanly to ``±π/2`` via ``atan2`` rather than dividing by zero.
+
+ Inverse of :func:`displacement_from_x_angle`."""
+ 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 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 = 0.9994,
+ line_tolerance: float = 0.05,
+) -> bool:
+ """True if both axis segments lie on the same infinite line in plan.
+
+ Two conditions: directions must be (anti-)parallel within ``parallel_threshold``
+ (``cos(2°) ≈ 0.9994``), AND any endpoint of B must lie on A's infinite line
+ within ``line_tolerance``. Plan-only (Z ignored) — two parallel walls at
+ different elevations are still considered collinear because the merge operator
+ handles Z resolution itself.
+
+ Used by the wall-join gizmo's state machine: collinear pair → Merge icon at the
+ boundary, perpendicular pair → Join icon at the intersection."""
+ 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 pair of endpoints between two segments.
+
+ For walls that meet end-to-end this is the shared corner; for walls with a
+ small gap it's the midpoint of the gap. Either way it's the user-meaningful
+ "boundary" where a merge would graft the two segments together."""
+ 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)
diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py
index e6a60c9deb..777187f161 100644
--- a/src/bonsai/bonsai/core/tool.py
+++ b/src/bonsai/bonsai/core/tool.py
@@ -668,6 +668,9 @@ class Model:
def export_profile(cls, obj, position=None): pass
def generate_occurrence_name(cls, element_type, ifc_class): pass
def get_extrusion(cls, representation): pass
+ def get_connected_slab_objs(cls, wall): pass
+ def get_connected_wall_objs(cls, slab): pass
+ def has_underside_connection(cls, element): pass
def get_manual_booleans(cls, element): pass
def get_material_layer_parameters(cls, element): pass
def get_slab_clipping_bmesh(cls, obj): pass
@@ -683,6 +686,7 @@ class Model:
def regenerate_profile(cls, obj): pass
def regenerate_slab(cls, obj): pass
def reload_body_representation(cls, obj_or_objects): pass
+ def remove_wall_to_underside_booleans(cls, wall): pass
def replace_object_ifc_representation(cls, ifc_file, ifc_context, obj, new_representation): pass
@@ -776,6 +780,12 @@ class Profile:
def get_profile(cls, element): pass
+@interface
+class Parametric:
+ def get_geom_generation(cls) -> int: pass
+ def refresh_post_commit(cls) -> None: pass
+
+
@interface
class Pset:
def add_proposed_property(cls, name, value, props): pass
diff --git a/src/bonsai/bonsai/tool/__init__.py b/src/bonsai/bonsai/tool/__init__.py
index 06e498e8be..31e93cace7 100644
--- a/src/bonsai/bonsai/tool/__init__.py
+++ b/src/bonsai/bonsai/tool/__init__.py
@@ -51,6 +51,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
diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py
index fac9657dbd..53f91c06da 100644
--- a/src/bonsai/bonsai/tool/blender.py
+++ b/src/bonsai/bonsai/tool/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.
from __future__ import annotations
@@ -55,12 +57,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,
@@ -1137,20 +1139,18 @@ class Blender(bonsai.core.tool.Blender):
:return: True if an action was taken, False otherwise
"""
+ # roof and railing both finalize then drop into path-edit mode — handle
+ # them before the generic finish dispatch so the path transition runs.
if cls.is_roof(element):
- if cls.is_editing_roof_parameters(obj):
- bpy.ops.bim.finish_editing_roof()
+ if (feature := tool.Parametric.find_by_name("roof")) and feature.is_editing(obj):
+ tool.Parametric.run_bim_op(feature.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()
+ if (feature := tool.Parametric.find_by_name("railing")) and feature.is_editing(obj):
+ tool.Parametric.run_bim_op(feature.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,20 +1161,13 @@ 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
@@ -1221,6 +1214,17 @@ class Blender(bonsai.core.tool.Blender):
def is_stair(cls, element: entity_instance) -> bool:
return tool.Pset.get_element_pset(element, "BBIM_Stair")
+ @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 not element.is_a("IfcWall"):
+ return False
+ return tool.Model.get_usage_type(element) == "LAYER2"
+
@classmethod
def is_editing_railing_path(cls, obj: bpy.types.Object):
props = tool.Model.get_railing_props(obj)
@@ -1231,34 +1235,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)
+ feature = tool.Parametric.find_for_element(element)
+ return bool(feature and feature.has_non_editable_path)
class Array:
@classmethod
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/geometry.py b/src/bonsai/bonsai/tool/geometry.py
index 0d690d0308..ae3bff6c97 100644
--- a/src/bonsai/bonsai/tool/geometry.py
+++ b/src/bonsai/bonsai/tool/geometry.py
@@ -225,7 +225,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)
@@ -1093,11 +1099,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
@@ -1210,6 +1221,8 @@ class Geometry(bonsai.core.tool.Geometry):
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
diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py
index 5ed3098bd7..14f1c4684d 100644
--- a/src/bonsai/bonsai/tool/model.py
+++ b/src/bonsai/bonsai/tool/model.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
@@ -77,6 +79,7 @@ if TYPE_CHECKING:
BIMRoofProperties,
BIMStairProperties,
BIMSverchokProperties,
+ BIMWallProperties,
BIMWindowProperties,
)
@@ -98,6 +101,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]
@@ -312,6 +319,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"):
@@ -804,6 +813,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
@@ -816,7 +876,8 @@ class Model(bonsai.core.tool.Model):
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
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
@@ -1305,8 +1366,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]:
@@ -2379,6 +2440,7 @@ 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()
@@ -2386,6 +2448,7 @@ class Model(bonsai.core.tool.Model):
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)):
diff --git a/src/bonsai/bonsai/tool/parametric.py b/src/bonsai/bonsai/tool/parametric.py
new file mode 100644
index 0000000000..ce6659976c
--- /dev/null
+++ b/src/bonsai/bonsai/tool/parametric.py
@@ -0,0 +1,460 @@
+# 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 + save-time auto-commit for parametric draft edits.
+
+Single source of truth: adding a new parametric element type is one entry in
+`Parametric.EDIT_TYPES`. Every consumer — save-time auto-commit, the
+finish/cancel chains in ``tool.Blender.Modifier``, the ``PointerProperty``
+attachment in ``bim/module/model/__init__.py``, and the per-type
+``GizmoPreferences`` registration in ``bim/__init__.py`` — derives the
+class names, operator ``bl_idname``s, and predicates from the registry entry's
+short ``name`` token.
+
+Lives in ``tool/`` so both ``tool/`` (e.g. ``tool/blender.py``) and ``bim/``
+modules can consume it without crossing the layer boundary. The orchestration
+helpers (``commit_object_draft``, ``commit_pending_edits``) call
+``bpy.ops.bim.*`` operators by name, which is runtime dispatch through Blender
+rather than a Python import of ``bim/``.
+
+----------------------------------------------------------------------
+How to add a new parametric object
+----------------------------------------------------------------------
+
+End-to-end walkthrough for wiring a new IFC element type (e.g. ``IfcSlab``)
+into the gizmo-driven parametric edit framework. Numbered steps are
+**required** unless flagged OPTIONAL. Keep this section in sync with the
+implementation files it references — if a step's example code stops matching
+the real registration site, the step is out of date.
+
+STEP 1 — Add the registry entry (this file)
+ Append to `Parametric.EDIT_TYPES`::
+
+ ParametricObject("slab", has_non_editable_path=False),
+
+ The ``name`` token drives every derived identifier:
+ ``BIMSlabProperties``, ``bim.enable_editing_slab`` /
+ ``bim.finish_editing_slab`` / ``bim.cancel_editing_slab``, and the
+ ``slab`` field on ``GizmoPreferences``. Set ``has_non_editable_path=True``
+ if the modifier exposes no user-editable path (cf. door, window, stair).
+
+STEP 2 — Define the ``PropertyGroup`` (``bim/module/model/prop.py``)
+ Class name **must** be ``BIMProperties`` — capitalisation matches
+ `ParametricObject.props_attr`::
+
+ class BIMSlabProperties(bpy.types.PropertyGroup):
+ is_editing: BoolProperty(...)
+ # ... per-type draft fields, snapshots, mesh_dirty, etc. ...
+
+ The ``is_editing`` flag is the single field every consumer of the registry
+ expects.
+
+STEP 3 — Register the PropertyGroup class
+ Add it to the ``classes`` tuple in ``bim/module/model/__init__.py`` (near
+ the existing ``prop.BIMProperties`` entries). The
+ ``bpy.types.Object.BIMSlabProperties`` attachment is automatic —
+ `Parametric.register_object_properties` loops the registry.
+
+STEP 4 — Implement the Enable / Finish / Cancel triad
+ In ``bim/module/model/slab.py``, define three ``bpy.types.Operator``
+ subclasses with the canonical ``bl_idname``\\s:
+
+ - ``EnableEditingSlab`` → ``bl_idname = "bim.enable_editing_slab"``
+ - ``FinishEditingSlab`` → ``bl_idname = "bim.finish_editing_slab"``
+ - ``CancelEditingSlab`` → ``bl_idname = "bim.cancel_editing_slab"``
+
+ **First, check if your new type fits one of the existing lifecycle
+ shapes** in `bonsai.bim.parametric_lifecycle`. If it does, inherit
+ the matching mixin and the triad collapses to ~25 lines total:
+
+ - ``FeatureModifierEditMixin`` — BBIM_ pset with nested
+ ``lining_properties`` / ``panel_properties``; Finish via
+ ``update__modifier_representation`` →
+ ``ifcopenshell.api.feature``; Cancel via
+ ``switch_representation`` to the Body rep. Reference samples:
+ door (multi-object) and window (single-object).
+
+ - ``PathPreservingEditMixin`` — BBIM_ pset whose ``path_data``
+ is preserved through edit; Finish via per-type
+ ``update_bbim__pset`` + ``update__modifier_ifc_data``;
+ Cancel rebuilds the bmesh preview. Reference samples: railing, roof.
+
+ If neither shape fits (the type needs validation-first lifecycle, an
+ explicit snapshot, delegate-to-sub-operators Finish, or a unique
+ post-Finish step) implement the triad standalone — see ``wall.py``
+ (validation/snapshot/delegate) or ``stair.py`` (raw pset JSON +
+ ``update_ifc_stair_props``) as references. Register all three in the
+ module's ``classes`` tuple.
+
+STEP 5 — Implement the gizmo group (same file)
+ Subclass ``BaseParametricGizmoGroup`` from
+ ``bim/module/drawing/gizmos.py``::
+
+ class GizmoSlabEdition(bpy.types.GizmoGroup, BaseParametricGizmoGroup):
+ bl_idname = "OBJECT_GGT_bim_slab_edition"
+
+ @classmethod
+ def is_element_type(cls, element):
+ return tool.Blender.Modifier.is_slab(element)
+
+ dimension_gizmo_props = [DimensionGizmoConfig(...)]
+
+ Register it in the ``classes`` tuple. The classmethod makes
+ ``tool.Blender.Modifier.is_slab(element)`` testable via the gizmo's
+ ``poll()``.
+
+STEP 6 — Add the element-type predicate (``tool/blender.py``)
+ Inside the ``Blender.Modifier`` class, alongside ``is_door`` / ``is_wall``::
+
+ @classmethod
+ def is_slab(cls, element: entity_instance) -> bool:
+ return tool.Pset.get_element_pset(element, "BBIM_Slab")
+
+ The method name **must** be ``is_`` to match
+ `ParametricObject.name` — `Parametric.find_for_element`
+ looks it up by string.
+
+STEP 7 — OPTIONAL: typed property accessor (``tool/model.py``)
+ Convenience helper for call sites that statically know the IFC type::
+
+ @classmethod
+ def get_slab_props(cls, obj) -> BIMSlabProperties:
+ return obj.BIMSlabProperties
+
+ Call sites that work generically (registry-driven) can use
+ ``getattr(obj, feature.props_attr)`` directly and skip this step.
+
+STEP 8 — OPTIONAL: gizmo visibility preferences (``bim/ui.py``)
+ For per-gizmo show/hide toggles, define::
+
+ class GizmoPreferencesSlab(bpy.types.PropertyGroup):
+ length: BoolProperty(name="Length", default=True, ...)
+ # ... one BoolProperty per gizmo ...
+
+ Then add a matching field on ``GizmoPreferences``::
+
+ slab: bpy.props.PointerProperty(type=GizmoPreferencesSlab)
+
+ Do **not** add ``GizmoPreferencesSlab`` to the ``classes`` list in
+ ``bim/__init__.py`` — the registry-driven discovery in this module finds
+ it by name (``GizmoPreferences`` + capitalised registry token) and
+ registers it automatically.
+
+STEP 9 — OPTIONAL: pure geometry helpers (``core/model.py``)
+ Per-type math (collinearity checks, slope/displacement conversions,
+ intersection helpers) lives here. The hard rule: no ``bpy`` /
+ ``ifcopenshell`` imports at module load — wrap them in
+ ``if TYPE_CHECKING:`` blocks only. Lets the helpers be unit-tested
+ headless via ``pytest test/core/``.
+
+STEP 10 — Verify
+ From ``src/bonsai/``::
+
+ ruff check .
+ black --check .
+ pytest test/core/ -x -q
+ blender -b -P runpytest.py -- test/bim/ -x -q -m model
+
+ The Blender-backed lane runs a registry smoke test that iterates the
+ EDIT_TYPES list and asserts each entry's enable/finish/cancel operator
+ resolves to a registered ``bpy.ops.bim.*``, that ``bpy.types.Object``
+ carries the matching ``BIMProperties`` attribute, and that the
+ ``is_`` predicate exists on ``tool.Blender.Modifier``. Forget any
+ of the steps above and that test fails with a precise pointer at
+ what's missing.
+
+ Then manually in Blender:
+
+ 1. Enable Bonsai → create an instance of the new IFC type.
+ 2. Run ``bim.enable_editing_`` → confirm the gizmo group polls in
+ and the dimension handles appear.
+ 3. Modify a draft field, save the file → confirm auto-commit fires
+ (watch the console for the ``parametric_commit`` log line).
+ 4. Disable + re-enable the addon → no ``bpy_struct: unknown property
+ type`` errors in the console (validates the register/unregister
+ symmetry driven by the registry)."""
+
+from __future__ import annotations
+
+import re
+import traceback
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Optional
+
+import bpy
+
+import bonsai.core.tool
+import bonsai.tool as tool
+
+if TYPE_CHECKING:
+ from ifcopenshell import entity_instance
+
+
+# ``name`` must be a single ASCII lowercase token starting with a letter:
+# ``str.capitalize()`` only handles single-word names cleanly, so a compound
+# token like ``"curtain_wall"`` would derive ``"BIMCurtain_wallProperties"`` —
+# off the Bonsai naming convention and silently broken.
+_VALID_NAME_RE = re.compile(r"^[a-z][a-z0-9]*$")
+
+
+@dataclass(frozen=True)
+class ParametricObject:
+ """One parametric element type's draft + enable + finish + cancel triad.
+
+ The short ``name`` token ("door", "window", "stair", "railing", "roof",
+ "wall", …) drives every derived identifier: the ``BIMProperties``
+ attribute on ``bpy.types.Object`` and the ``bim.enable_editing_`` /
+ ``bim.finish_editing_`` / ``bim.cancel_editing_`` operator
+ ``bl_idname``s. The ``name`` is validated at construction time — a
+ multi-word IFC type would silently mis-derive through
+ ``str.capitalize()`` and breaks the single-token assumption.
+
+ ``has_non_editable_path`` flags element types whose modifier exposes no
+ user-editable path (door, window, stair).
+
+ The paired runtime predicate ``tool.Blender.Modifier.is_(element)``
+ is part of the registry contract: it MUST be **total** — accept any
+ IFC entity and return a boolean, never raise. The registry iterates
+ every predicate against the active element on save; a raising predicate
+ propagates upward and breaks the save path for *all* parametric types,
+ not just its own."""
+
+ name: str
+ has_non_editable_path: bool = False
+
+ def __post_init__(self) -> None:
+ if not _VALID_NAME_RE.match(self.name):
+ raise ValueError(
+ f"ParametricObject name {self.name!r} must be a single ASCII lowercase "
+ f"token matching {_VALID_NAME_RE.pattern!r}. ``str.capitalize()`` only "
+ f"handles single-word names — compound IFC types need an explicit "
+ f"naming override (not yet supported)."
+ )
+
+ @property
+ def props_attr(self) -> str:
+ return f"BIM{self.name.capitalize()}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):
+ EDIT_TYPES: list[ParametricObject] = [
+ ParametricObject("door", has_non_editable_path=True),
+ ParametricObject("window", has_non_editable_path=True),
+ ParametricObject("stair", has_non_editable_path=True),
+ ParametricObject("railing"),
+ ParametricObject("roof"),
+ ParametricObject("wall"),
+ ]
+
+ _geom_generation: int = 0
+
+ @classmethod
+ def get_geom_generation(cls) -> int:
+ return cls._geom_generation
+
+ @classmethod
+ def refresh_post_commit(cls) -> None:
+ """Post-commit hook for ``tool.Ifc.Operator``: re-syncs scene-level
+ ``BIMModelProperties`` (workspace tool header H/L/A fields) from current
+ IFC state and bumps the geometry generation counter so per-gizmo-group
+ caches keyed off it drop their stale entries on the next draw.
+
+ Why this exists: ``update_bim_tool_props`` was historically only wired
+ to the active-object msgbus, so in-place IFC mutations on the current
+ selection (S_E, C_E, change_extrusion_*, …) left the header showing
+ stale values until the user changed selection. Same shape of bug for
+ the wall gizmo cache: ``GizmoGroup.refresh()`` only fires on Blender's
+ own state-change events, not on every ``bpy.ops.bim.*`` mutation.
+
+ Cheap when nothing parametric is active — ``update_bim_tool_props``
+ early-returns when no Bonsai workspace tool is selected or the active
+ object isn't an IFC element."""
+ import bonsai.bim.handler # late import: bim.handler imports tool.*
+
+ cls._geom_generation += 1
+ bonsai.bim.handler.update_bim_tool_props()
+ screen = getattr(bpy.context, "screen", None)
+ if screen is not None:
+ for area in screen.areas:
+ if area.type == "VIEW_3D":
+ area.tag_redraw()
+
+ @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 find_for_element(cls, element: entity_instance) -> Optional[ParametricObject]:
+ """Return the registry entry whose IFC type predicate matches ``element``.
+
+ The per-type predicate lives at ``tool.Blender.Modifier.is_``;
+ resolved here by attribute lookup at call time, which avoids a
+ ``tool.parametric`` ↔ ``tool.blender`` import cycle."""
+ for feature in cls.EDIT_TYPES:
+ predicate = getattr(tool.Blender.Modifier, f"is_{feature.name}", None)
+ if predicate is not None and predicate(element):
+ return feature
+ return None
+
+ @classmethod
+ def is_object_editing(cls, obj: bpy.types.Object) -> Optional[ParametricObject]:
+ for feature in cls.EDIT_TYPES:
+ if feature.is_editing(obj):
+ return feature
+ return None
+
+ @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. The first registry match per object wins."""
+ return [(obj, feature.finish_op) for obj in bpy.data.objects if (feature := cls.is_object_editing(obj))]
+
+ @classmethod
+ def run_bim_op(cls, bl_idname: str) -> None:
+ """Invoke a ``bim.*`` operator by its ``bl_idname``.
+
+ Constraint enforced via ``assert``: the operator MUST be a
+ ``tool.Ifc.Operator`` subclass — its transaction wrap is what
+ makes the IFC mutation undo-aware. Direct ``bpy.ops.bim.*`` invocation
+ of a non-``Ifc.Operator`` would mutate IFC outside Bonsai's
+ transaction system."""
+ verb = bl_idname.removeprefix("bim.")
+ op_cls = getattr(bpy.types, f"BIM_OT_{verb}", None)
+ assert op_cls is not None and issubclass(
+ op_cls, tool.Ifc.Operator
+ ), 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 True on success, False if
+ the operator raised (with traceback printed to the console).
+
+ 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 as e:
+ print(f"Bonsai: commit of {obj.name!r} via {finish_op} failed: {e}")
+ traceback.print_exc()
+ 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.
+
+ Each finish op wraps its own IFC transaction, so N pending drafts
+ produce N+1 undo entries (one per commit, plus the save). Ctrl+Z
+ walks back through commits individually — intentional, each commit
+ is reversible on its own."""
+ 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 of `commit_pending_edits`. ``names``
+ filters which registry entries to consider — e.g. ``("wall",)`` to commit
+ only wall drafts among selected objects; ``None`` considers every type.
+
+ Used by multi-object operators (``bim.unjoin_walls``, ``bim.merge_wall``,
+ ``bim.extend_walls_to_wall`` etc.) that must run against committed IFC
+ state — running them with a wall whose draft hasn't been flushed leaves
+ stale gizmos pointing at obsolete IFC numbers."""
+ committed = 0
+ failed: list[bpy.types.Object] = []
+ for obj in tool.Blender.get_selected_objects():
+ feature = cls.is_object_editing(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 register_object_properties(cls, prop_module) -> None:
+ """Attach ``bpy.types.Object.BIMProperties`` for every registered
+ parametric type, looking up the matching ``PropertyGroup`` class on
+ ``prop_module``. Skips entries whose ``PropertyGroup`` class is absent."""
+ for feature in cls.EDIT_TYPES:
+ prop_cls = getattr(prop_module, feature.props_attr, None)
+ if prop_cls is None:
+ continue
+ setattr(bpy.types.Object, feature.props_attr, bpy.props.PointerProperty(type=prop_cls))
+
+ @classmethod
+ def unregister_object_properties(cls) -> None:
+ for feature in cls.EDIT_TYPES:
+ if hasattr(bpy.types.Object, feature.props_attr):
+ delattr(bpy.types.Object, feature.props_attr)
+
+ @classmethod
+ def iter_gizmo_preference_classes(cls, ui_module) -> list[type]:
+ """``GizmoPreferences`` classes that exist on ``ui_module`` for
+ every registry entry. Order matches `EDIT_TYPES`. Used by
+ ``bim/__init__.py`` to inject the per-type ``GizmoPreferences``
+ classes at the correct point — before ``ui.GizmoPreferences``, which
+ references them via ``PointerProperty``."""
+ out: list[type] = []
+ for feature in cls.EDIT_TYPES:
+ gpref = getattr(ui_module, f"GizmoPreferences{feature.name.capitalize()}", None)
+ if gpref is not None:
+ out.append(gpref)
+ return out
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/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..bfae14c6f6 100644
--- a/src/bonsai/test/bim/feature/model.feature
+++ b/src/bonsai/test/bim/feature/model.feature
@@ -673,6 +673,129 @@ Scenario: Create door type based on door modifier, add an occurrence of it and e
And I press "bim.finish_editing_door()"
Then nothing happens
+Scenario: Saving with a door mid-edit auto-commits the draft value to the IFC pset
+ Given an empty IFC project
+ And I trigger "Add Element"
+ And I set the "Class" property to "IfcDoorType"
+ And I set the "Predefined Type" property to "DOOR"
+ And I set the "Representation" property to "Door"
+ When I click "OK"
+ And I press "bim.add_occurrence"
+ And I press "bim.enable_editing_door()"
+ And I set "active_object.BIMDoorProperties.overall_height" to "2.5"
+ Then "active_object.BIMDoorProperties.is_editing" is "True"
+ When I press "bim.save_project(filepath='{temp_project_path}', should_save_as=True)"
+ Then "active_object.BIMDoorProperties.is_editing" is "False"
+ And the variable "saved_height" is "__import__('json').loads(ifcopenshell.util.element.get_pset({ifc}.by_type('IfcDoor')[0], 'BBIM_Door', 'Data'))['overall_height']"
+ And the variable "saved_height" equals "2.5"
+
+Scenario: Saving with no parametric edits in progress leaves the door pset unchanged
+ Given an empty IFC project
+ And I trigger "Add Element"
+ And I set the "Class" property to "IfcDoorType"
+ And I set the "Predefined Type" property to "DOOR"
+ And I set the "Representation" property to "Door"
+ When I click "OK"
+ And I press "bim.add_occurrence"
+ And the variable "pre_save_height" is "__import__('json').loads(ifcopenshell.util.element.get_pset({ifc}.by_type('IfcDoor')[0], 'BBIM_Door', 'Data'))['overall_height']"
+ When I press "bim.save_project(filepath='{temp_project_path}', should_save_as=True)"
+ Then the variable "post_save_height" is "__import__('json').loads(ifcopenshell.util.element.get_pset({ifc}.by_type('IfcDoor')[0], 'BBIM_Door', 'Data'))['overall_height']"
+ And the variable "post_save_height" equals "{pre_save_height}"
+
+Scenario: Saving with a wall mid-edit auto-commits the draft to IFC
+ Given an empty IFC project
+ And I add a cube
+ And the object "Cube" is selected
+ And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
+ And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
+ And I press "bim.assign_class"
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
+ And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
+ And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
+ And I press "bim.add_occurrence"
+ And the object "IfcWall/Wall" is selected
+ And I press "bim.enable_editing_wall()"
+ Then "active_object.BIMWallProperties.is_editing" is "True"
+ When I press "bim.save_project(filepath='{temp_project_path}', should_save_as=True)"
+ Then "active_object.BIMWallProperties.is_editing" is "False"
+
+Scenario: Enabling and finishing a wall edit with no drag is a no-op
+ Given an empty IFC project
+ And I add a cube
+ And the object "Cube" is selected
+ And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
+ And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
+ And I press "bim.assign_class"
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
+ And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
+ And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
+ And I press "bim.add_occurrence"
+ And the object "IfcWall/Wall" is selected
+ And the variable "entity_count_before" is "len(list({ifc}))"
+ When I press "bim.enable_editing_wall()"
+ And I press "bim.finish_editing_wall()"
+ Then "active_object.BIMWallProperties.is_editing" is "False"
+ And "len(list({ifc}))" is "{entity_count_before}"
+
+Scenario: Cancelling a wall edit clears is_editing
+ Given an empty IFC project
+ And I add a cube
+ And the object "Cube" is selected
+ And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
+ And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
+ And I press "bim.assign_class"
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
+ And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
+ And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
+ And I press "bim.add_occurrence"
+ And the object "IfcWall/Wall" is selected
+ And I press "bim.enable_editing_wall()"
+ When I press "bim.cancel_editing_wall()"
+ Then "active_object.BIMWallProperties.is_editing" is "False"
+
+Scenario: Wall parametric edit works on IFC2X3 projects
+ Given an empty IFC2X3 project
+ And I add a cube
+ And the object "Cube" is selected
+ And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
+ And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
+ And I press "bim.assign_class"
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
+ And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
+ And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
+ And I press "bim.add_occurrence"
+ And the object "IfcWall/Wall" is selected
+ When I press "bim.enable_editing_wall()"
+ Then "active_object.BIMWallProperties.is_editing" is "True"
+ When I press "bim.finish_editing_wall()"
+ Then "active_object.BIMWallProperties.is_editing" is "False"
+
+Scenario: Rotate a wall 90° via bim.rotate_wall_90
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
+ And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
+ And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
+ And I press "bim.add_occurrence"
+ And the object "IfcWall/Wall" is selected
+ When I press "bim.rotate_wall_90()"
+ Then the object "IfcWall/Wall" dimensions are "1,0.1,3"
+ And the object "IfcWall/Wall" bottom left corner is at "0,0,0"
+ And the object "IfcWall/Wall" top right corner is at "-0.1,1,3"
+
+Scenario: Splitting a wall with another wall mid-edit commits the pending edit first
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
+ And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
+ And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
+ And I press "bim.add_occurrence"
+ And the object "IfcWall/Wall" is selected
+ And I press "bim.enable_editing_wall()"
+ Then "active_object.BIMWallProperties.is_editing" is "True"
+ When I press "bim.split_wall()"
+ Then "active_object.BIMWallProperties.is_editing" is "False"
+
Scenario: Create a door, undo and create a new door
Given an empty IFC project
And I prepare to undo
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/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/test_stair_gizmos.py b/src/bonsai/test/bim/module/model/test_stair_gizmos.py
new file mode 100644
index 0000000000..060fdbcbe5
--- /dev/null
+++ b/src/bonsai/test/bim/module/model/test_stair_gizmos.py
@@ -0,0 +1,135 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+"""Regression guard for the stair icon billboard fix.
+
+Before the fix, ``set_icon_gizmo_position`` in ``bim.module.drawing.gizmos``
+composed ``mw @ (Translation @ billboard_rot @ Scale)``, which applied the
+stair's world rotation on top of the billboard rotation. The result was
+icons (validate / cancel / lock / +/- / cycle / tread_lock) drawn edge-on
+to the camera for any stair rotated in plan — effectively unclickable.
+
+The fix routes through ``billboarded_at(world_pos, billboard_rot, scale)``,
+which computes ``Translation(world_pos) @ billboard_rot @ Scale`` — the
+object's rotation is folded into the translation only, never the rotation."""
+
+import math
+import types
+
+import bpy
+import pytest
+
+pytestmark = pytest.mark.model
+
+
+@pytest.fixture(autouse=True)
+def _require_real_bpy():
+ if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
+ pytest.skip("requires real Blender (bpy is mocked or absent)")
+
+
+def _rotation_close(a, b, tol: float = 1e-6) -> bool:
+ for row_a, row_b in zip(a, b):
+ for va, vb in zip(row_a, row_b):
+ if abs(va - vb) > tol:
+ return False
+ return True
+
+
+@pytest.mark.parametrize("angle_deg", [0, 30, 45, 90, 135, 217])
+def test_billboarded_at_rotation_is_pure_billboard(angle_deg):
+ """Object rotation must not leak into the gizmo's rotation part."""
+ from mathutils import Matrix, Vector
+
+ from bonsai.bim.module.drawing.gizmos import billboarded_at
+
+ mw = Matrix.Rotation(math.radians(angle_deg), 4, "Z") @ Matrix.Translation((3, 4, 5))
+ billboard_rot = Matrix.Rotation(math.radians(30), 4, "X")
+
+ world_pos = mw @ Vector((1, 0, 2))
+ result = billboarded_at(world_pos, billboard_rot, scale=0.5)
+
+ # The rotation part of result, after stripping the 0.5 uniform scale,
+ # must equal billboard_rot — no contribution from mw's rotation.
+ rotation_part = result.to_3x3() * 2.0
+ assert _rotation_close(rotation_part.to_4x4(), billboard_rot)
+
+
+def test_billboarded_at_translation_is_world_pos():
+ """Translation lands exactly at the world-space target."""
+ from mathutils import Matrix, Vector
+
+ from bonsai.bim.module.drawing.gizmos import billboarded_at
+
+ world_pos = Vector((1.23, 4.56, 7.89))
+ result = billboarded_at(world_pos, Matrix.Identity(4), scale=0.5)
+ assert (result.translation - world_pos).length < 1e-6
+
+
+def test_set_icon_gizmo_position_does_not_apply_object_rotation():
+ """End-to-end: the helper used by every stair icon (and shared with all
+ parametric gizmo groups) must produce a matrix whose rotation part is
+ billboard_rot, not mw_rotation @ billboard_rot. This is the exact bug
+ that left stair icons edge-on to the camera."""
+ from mathutils import Matrix, Vector
+
+ from bonsai.bim.module.drawing.gizmos import (
+ BaseParametricGizmoGroup,
+ billboarded_at,
+ )
+
+ # Same inputs as the real call site (stair.py:747-765), but we drive the
+ # helper directly so we don't need a registered GizmoGroup. We bind a
+ # stand-in `get_gizmo_if_visible` that returns a tiny mock; the helper's
+ # observable output is the matrix_basis it assigns.
+ captured = {}
+
+ class _GizmoStub:
+ matrix_basis = Matrix.Identity(4)
+
+ stub = _GizmoStub()
+
+ def _fake_get(name):
+ captured["name"] = name
+ return stub
+
+ # Bind the helper to a throwaway instance so `self.get_gizmo_if_visible`
+ # resolves to our stub without registering a real GizmoGroup with Blender.
+ fake_self = types.SimpleNamespace(get_gizmo_if_visible=_fake_get)
+ mw = Matrix.Rotation(math.radians(45), 4, "Z") @ Matrix.Translation((3, 4, 5))
+ billboard_rot = Matrix.Rotation(math.radians(30), 4, "X")
+ local_pos = Vector((1, 0, 2))
+ BaseParametricGizmoGroup.set_icon_gizmo_position(
+ fake_self,
+ "validate_gizmo",
+ mw=mw,
+ x=local_pos.x,
+ y=local_pos.y,
+ z=local_pos.z,
+ billboard_rot=billboard_rot,
+ scale=0.5,
+ )
+
+ expected = billboarded_at(mw @ local_pos, billboard_rot, 0.5)
+
+ assert captured["name"] == "validate_gizmo"
+ for row_a, row_b in zip(stub.matrix_basis, expected):
+ for va, vb in zip(row_a, row_b):
+ assert abs(va - vb) < 1e-6
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..3fd5699ef2
--- /dev/null
+++ b/src/bonsai/test/bim/module/model/test_wall_gizmos.py
@@ -0,0 +1,181 @@
+# 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."""
+
+import types
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import bpy
+import pytest
+
+pytestmark = pytest.mark.wall
+
+
+@pytest.fixture(autouse=True)
+def _require_real_bpy():
+ if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
+ pytest.skip("requires real Blender (bpy is mocked or absent)")
+
+
+def _make_context(active, selected):
+ """Build a minimal ``context`` stub with the two attributes ``poll()`` reads."""
+ return SimpleNamespace(active_object=active, selected_objects=list(selected))
+
+
+def _patch_tools(prefs_on, selected, active_element, other_element, active_usage, other_usage):
+ """Return a stack of patches that simulate one selection / IFC state for poll().
+
+ ``prefs.gizmos.draw_gizmos_in_3d_viewport`` is the top-level toggle. The
+ selection set, the IFC entity lookup, and the usage-type lookup are stubbed
+ so the test only depends on the predicate ordering in poll()."""
+ prefs = SimpleNamespace(gizmos=SimpleNamespace(draw_gizmos_in_3d_viewport=prefs_on))
+
+ entity_map = {}
+ usage_map = {}
+ # active_element/other_element are matched by object identity from the selected set
+ if len(selected) == 2:
+ entity_map[id(selected[0])] = active_element
+ entity_map[id(selected[1])] = other_element
+ usage_map[id(active_element)] = active_usage
+ usage_map[id(other_element)] = other_usage
+
+ def get_entity(obj):
+ return entity_map.get(id(obj))
+
+ def get_usage_type(element):
+ return usage_map.get(id(element))
+
+ from bonsai import tool
+
+ return [
+ patch.object(tool.Blender, "get_addon_preferences", return_value=prefs),
+ patch.object(tool.Blender, "get_selected_objects", return_value=set(selected)),
+ patch.object(tool.Ifc, "get_entity", side_effect=get_entity),
+ patch.object(tool.Model, "get_usage_type", side_effect=get_usage_type),
+ ]
+
+
+def _run_poll(prefs_on, active_is_in_selected, len_override, active_usage, other_usage, active_has_entity=True):
+ from bonsai.bim.module.model.wall import GizmoWallExtendVertically
+
+ slab_obj = object()
+ wall_obj = object()
+ active = slab_obj if active_is_in_selected else object()
+ if len_override is None:
+ selected = [slab_obj, wall_obj]
+ else:
+ selected = [object() for _ in range(len_override)]
+ if active_is_in_selected and selected:
+ active = selected[0]
+
+ slab_element = object() if active_has_entity else None
+ wall_element = object()
+
+ patches = _patch_tools(prefs_on, selected, slab_element, wall_element, active_usage, other_usage)
+ for p in patches:
+ p.start()
+ try:
+ return GizmoWallExtendVertically.poll(_make_context(active, selected))
+ finally:
+ for p in patches:
+ p.stop()
+
+
+def test_poll_accepts_layer3_active_with_layer2_other():
+ assert (
+ _run_poll(
+ prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage="LAYER2"
+ )
+ is True
+ )
+
+
+def test_poll_rejects_when_gizmo_toggle_off():
+ assert (
+ _run_poll(
+ prefs_on=False, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage="LAYER2"
+ )
+ is False
+ )
+
+
+def test_poll_rejects_when_selection_count_is_not_two():
+ assert (
+ _run_poll(
+ prefs_on=True, active_is_in_selected=True, len_override=3, active_usage="LAYER3", other_usage="LAYER2"
+ )
+ is False
+ )
+ assert (
+ _run_poll(
+ prefs_on=True, active_is_in_selected=True, len_override=1, active_usage="LAYER3", other_usage="LAYER2"
+ )
+ is False
+ )
+
+
+def test_poll_rejects_when_active_has_no_ifc_entity():
+ assert (
+ _run_poll(
+ prefs_on=True,
+ active_is_in_selected=True,
+ len_override=None,
+ active_usage="LAYER3",
+ other_usage="LAYER2",
+ active_has_entity=False,
+ )
+ is False
+ )
+
+
+def test_poll_rejects_when_active_is_not_layer3():
+ # A LAYER2 active (wall) must NOT trigger this gizmo — the wall-join gizmo
+ # owns that case, and extend_walls_to_underside expects the slab to be active.
+ assert (
+ _run_poll(
+ prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER2", other_usage="LAYER2"
+ )
+ is False
+ )
+ # Active with no usage at all (generic mesh, e.g. an opening blocker) is also rejected.
+ assert (
+ _run_poll(prefs_on=True, active_is_in_selected=True, len_override=None, active_usage=None, other_usage="LAYER2")
+ is False
+ )
+
+
+def test_poll_rejects_when_other_is_not_layer2_wall():
+ assert (
+ _run_poll(
+ prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage="LAYER3"
+ )
+ is False
+ )
+ assert (
+ _run_poll(prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage=None)
+ is False
+ )
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..933fab2454
--- /dev/null
+++ b/src/bonsai/test/bim/module/model/test_wall_header_refresh.py
@@ -0,0 +1,87 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+"""Regression tests for the post-IFC-commit refresh path that re-syncs the
+workspace tool header (``BIMModelProperties``) and invalidates the per-wall
+gizmo geometry cache.
+
+Bug repro before the fix: hotkey operators that edited the active wall in
+place (``bpy.ops.bim.hotkey(hotkey="S_E")`` / ``"C_E"``) mutated IFC but never
+fired ``active_object_callback`` (no selection change), so the header H/L/A
+fields and the gizmo cache both kept showing stale values. ``refresh_ui_data``
+ran, but it never resynced ``BIMModelProperties`` and never invalidated the
+per-gizmo-group geometry cache. The fix wires both refreshes through
+``tool.Parametric.refresh_post_commit`` and calls it from every
+``tool.Ifc.Operator`` epilogue."""
+
+import types
+from unittest.mock import patch
+
+import bpy
+import pytest
+
+pytestmark = pytest.mark.wall
+
+
+@pytest.fixture(autouse=True)
+def _require_real_bpy():
+ if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
+ pytest.skip("requires real Blender (bpy is mocked or absent)")
+
+
+def test_refresh_post_commit_bumps_generation_and_resyncs_header():
+ """``refresh_post_commit`` must bump the generation counter and call
+ ``update_bim_tool_props`` so the workspace tool header re-syncs from IFC."""
+ import bonsai.bim.handler as handler
+ from bonsai import tool
+
+ before = tool.Parametric.get_geom_generation()
+ with patch.object(handler, "update_bim_tool_props") as mock_resync:
+ tool.Parametric.refresh_post_commit()
+ assert tool.Parametric.get_geom_generation() == before + 1
+ mock_resync.assert_called_once()
+
+
+def test_geom_generation_invalidates_wall_geom_cache():
+ """Bumping the generation must cause ``_get_wall_geom_cached`` to drop its
+ stored entries on the next read, even when the same gizmo group instance
+ and the same wall object are reused (the case Blender's
+ ``GizmoGroup.refresh()`` does not cover)."""
+ from bonsai import tool
+ from bonsai.bim.module.model import wall as wall_mod
+
+ class _FakeGroup:
+ pass
+
+ group = _FakeGroup()
+ fake_obj = types.SimpleNamespace(name="Wall/W001")
+ sentinel_a = {"length": 1.0, "height": 2.0, "x_angle": 0.0}
+ sentinel_b = {"length": 1.5, "height": 2.5, "x_angle": 0.0}
+
+ with patch.object(wall_mod, "_read_wall_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/test_feature.py b/src/bonsai/test/bim/test_feature.py
index 79e8494441..348fa7f899 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
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..4142f51e63
--- /dev/null
+++ b/src/bonsai/test/bim/test_parametric_lifecycle.py
@@ -0,0 +1,409 @@
+# 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
+ # edit_pset is called exactly once; properties key is "Data" wrapping JSON.
+ patched_tool_and_ifc["ifc"].api.pset.edit_pset.assert_called_once()
+ kwargs = patched_tool_and_ifc["ifc"].api.pset.edit_pset.call_args.kwargs
+ assert "properties" in kwargs and "Data" in kwargs["properties"]
+
+
+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 _update_modifier_bmesh(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_update_modifier_bmesh(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..ec4383dccb
--- /dev/null
+++ b/src/bonsai/test/bim/test_parametric_registry.py
@@ -0,0 +1,157 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# 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`` registration) derives
+identifiers from each entry's short ``name`` token. Forget any downstream
+registration and the silent-desync the framework exists to prevent will ship.
+
+These tests pin the registry-to-runtime contract: for every entry the operator
+``bl_idname``s resolve to registered ``bpy.ops.bim.*`` callables, the
+``PropertyGroup`` class is attached to ``bpy.types.Object``, and the per-type
+predicate exists on `tool.Blender.Modifier`."""
+
+import types
+
+import bpy
+import pytest
+
+pytestmark = pytest.mark.model
+
+
+@pytest.fixture(autouse=True)
+def _require_real_bpy():
+ if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
+ pytest.skip("requires real Blender (bpy is mocked or absent)")
+
+
+@pytest.fixture
+def registry():
+ from bonsai import tool
+
+ return tool.Parametric.EDIT_TYPES
+
+
+def test_registry_is_non_empty(registry):
+ assert len(registry) >= 1
+
+
+def test_every_entry_has_enable_op_registered(registry):
+ missing = [e.enable_op for e in registry if not hasattr(bpy.ops.bim, e.enable_op.removeprefix("bim."))]
+ assert not missing, f"Missing enable operators: {missing}"
+
+
+def test_every_entry_has_finish_op_registered(registry):
+ missing = [e.finish_op for e in registry if not hasattr(bpy.ops.bim, e.finish_op.removeprefix("bim."))]
+ assert not missing, f"Missing finish operators: {missing}"
+
+
+def test_every_entry_has_cancel_op_registered(registry):
+ missing = [e.cancel_op for e in registry if not hasattr(bpy.ops.bim, e.cancel_op.removeprefix("bim."))]
+ assert not missing, f"Missing cancel operators: {missing}"
+
+
+def test_every_entry_has_property_group_attached(registry):
+ # ``register_object_properties`` runs at addon enable; if any entry's
+ # PropertyGroup class is missing on prop module the attribute is skipped.
+ missing = [e.props_attr for e in registry if not hasattr(bpy.types.Object, e.props_attr)]
+ assert not missing, (
+ f"bpy.types.Object missing attributes: {missing} — "
+ f"verify the matching PropertyGroup classes exist in bim.module.model.prop"
+ )
+
+
+def test_every_entry_has_modifier_predicate(registry):
+ from bonsai import tool
+
+ missing = [e.name for e in registry if getattr(tool.Blender.Modifier, f"is_{e.name}", None) is None]
+ assert not missing, f"tool.Blender.Modifier missing is_ 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.Blender.Modifier, f"is_{feature.name}", None)
+ if predicate is None:
+ continue
+ try:
+ predicate(probe)
+ except Exception as e:
+ raised.append((feature.name, type(e).__name__, str(e)))
+ assert not raised, (
+ f"is_ predicates raised on a non-matching IfcAnnotation: {raised}. "
+ f"Predicates must be total — return bool, never raise. Add an "
+ f"`if not element.is_a('IfcXxx'): return False` short-circuit or guard the pset lookup."
+ )
+
+
+def test_gizmo_preferences_attached_when_class_exists(registry):
+ """For every registry entry whose ``GizmoPreferences`` class exists in
+ ``bonsai.bim.ui``, the matching sub-PointerProperty must be declared on
+ ``ui.GizmoPreferences`` under the registry entry's ``name`` token.
+
+ Catches the silent-skip behaviour of the registry-driven gizmo-prefs
+ discovery: a typo in the class name or a dropped registration would
+ otherwise produce a missing sub-panel at runtime with no error.
+ Entries without a ``GizmoPreferences`` class are allowed — not
+ every parametric type ships gizmo prefs.
+
+ Checks ``__annotations__`` rather than ``hasattr`` because Blender's
+ PropertyGroup syntax (``field: bpy.props.PointerProperty(...)``) is an
+ annotation-only assignment — the attribute only materialises on the
+ class after Blender's metaclass installs the bpy_struct descriptor,
+ which depends on registration timing. Reading ``__annotations__``
+ pins the source-level contract independently of when register() ran."""
+ from bonsai.bim import ui
+
+ annotations = getattr(ui.GizmoPreferences, "__annotations__", {})
+ missing = []
+ for feature in registry:
+ prefs_class_name = f"GizmoPreferences{feature.name.capitalize()}"
+ if not hasattr(ui, prefs_class_name):
+ continue
+ if feature.name not in annotations:
+ missing.append((feature.name, prefs_class_name))
+ assert not missing, (
+ f"ui.GizmoPreferences missing sub-PointerProperty field(s) for: {missing} — "
+ f"each registered ``GizmoPreferences`` class must have a matching "
+ f"``: PointerProperty(type=GizmoPreferences)`` field on "
+ f"``ui.GizmoPreferences``"
+ )
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/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/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/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/ifcgeom/function_item_evaluator.cpp b/src/ifcgeom/function_item_evaluator.cpp
index 9bacf94ac0..9458a04fea 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/mapping/IfcPointByDistanceExpression.cpp b/src/ifcgeom/mapping/IfcPointByDistanceExpression.cpp
index 180226fb82..f07234a8f1 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().get() * 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 edfdd3a13b..a44ef1a57a 100644
--- a/src/ifcgeom/mapping/mapping.cpp
+++ b/src/ifcgeom/mapping/mapping.cpp
@@ -562,6 +562,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);
@@ -569,6 +570,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/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/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/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 962dbbb34f..f4ea932933 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:
@@ -537,7 +538,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 d7595ad6cb..3981dbc421 100644
--- a/src/ifcopenshell-python/ifcopenshell/express/schema_class.py
+++ b/src/ifcopenshell-python/ifcopenshell/express/schema_class.py
@@ -420,7 +420,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)
@@ -547,7 +555,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 28caafc262..3345eee5a6 100644
--- a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi
+++ b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi
@@ -1695,7 +1695,7 @@ class type_declaration(declaration):
class uninitialized_tag: ...
-def arrange_polygons(polygons): ...
+def arrange_polygons(settings, polygons): ...
def clear_schemas(): ...
def construct_iterator(geometry_library, settings, file, num_threads): ...
def construct_iterator_with_include_exclude(geometry_library, settings, file, elems, include, num_threads): ...
diff --git a/src/ifcopenshell-python/ifcopenshell/simple_spf b/src/ifcopenshell-python/ifcopenshell/simple_spf
index ed50b756c4..9400d243d8 160000
--- a/src/ifcopenshell-python/ifcopenshell/simple_spf
+++ b/src/ifcopenshell-python/ifcopenshell/simple_spf
@@ -1 +1 @@
-Subproject commit ed50b756c4035290d50eb2d928f89423f3aa0947
+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/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/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/ifcparse/IfcHierarchyHelper.h b/src/ifcparse/IfcHierarchyHelper.h
index 019cbf69c1..cfe37b3717 100644
--- a/src/ifcparse/IfcHierarchyHelper.h
+++ b/src/ifcparse/IfcHierarchyHelper.h
@@ -475,7 +475,7 @@ class IFC_PARSE_API IfcHierarchyHelper : public IfcParse::IfcFile {
t->set_attribute_value(1, owner_hist);
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/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/svgfill/src/arrange_polygons.cpp b/src/svgfill/src/arrange_polygons.cpp
index ba8785a267..260b22809e 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