Compare commits

..

1 Commits

Author SHA1 Message Date
Petru Conduraru 92f9dc9d60 ifcopenshell.util.element: dedupe SET-typed attributes in replace_attribute
replace_attribute() rewrites references inside aggregate attributes via
element.walk(), but never checked whether the replacement value was
already present elsewhere in the same aggregate. For an EXPRESS SET
(e.g. IfcProject.RepresentationContexts, IfcRelAggregates.RelatedObjects)
this can leave the same reference listed twice, which is invalid IFC.
LIST and BAG aggregates legitimately allow duplicates, so a blanket dedup
would be wrong; only SET-typed attributes are deduplicated, determined at
runtime from the schema declaration (IfcOpenShell#8706 review comment).

The SET/LIST/BAG check is cached per (schema, class, attribute index), and
the dedup pass itself only runs when a cheap linear pre-check finds the
replacement value already present in the aggregate, so the common case
(no duplicate produced) pays only that pre-check, not a hash-set rebuild.
Benchmarked against a 23MB (431k entities) and a 104MB (2.4M entities) IFC
model against a large SET attribute: worst case adds well under 1ms per
call; the realistic case (merging duplicate contexts, matching the PR
#8706 scenario) shows no measurable regression.

Fixes the root cause flagged in IfcOpenShell#8706 (Moult), obviating the
need for MergeDuplicateContexts' own manual aggregate-dedup pass for that
scenario.

Generated with the assistance of an AI coding tool.
2026-07-20 15:54:59 +03:00
176 changed files with 695 additions and 10210 deletions
+2 -2
View File
@@ -21,12 +21,12 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
submodules: recursive
- name: Checkout Build Repository
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
repository: IfcOpenShell/build-outputs
path: ./build
+2 -2
View File
@@ -9,13 +9,13 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
submodules: recursive
path: IfcOpenShell
- name: Checkout Build Repository
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
repository: IfcOpenShell/build-outputs
path: ifcopenshell_build
+2 -2
View File
@@ -35,12 +35,12 @@ jobs:
aws --version
- name: Checkout Repository
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
submodules: recursive
- name: Checkout Build Repository
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
repository: IfcOpenShell/build-outputs
path: ./build
+2 -2
View File
@@ -35,12 +35,12 @@ jobs:
aws --version
- name: Checkout Repository
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
submodules: recursive
- name: Checkout Build Repository
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
repository: IfcOpenShell/build-outputs
path: ./build
+2 -2
View File
@@ -27,12 +27,12 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
submodules: recursive
- name: Checkout Build Repository
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
repository: IfcOpenShell/build-outputs
path: ${{ matrix.deps_dir }}
+2 -2
View File
@@ -19,8 +19,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v7 # https://github.com/actions/checkout
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
- uses: actions/checkout@v6 # https://github.com/actions/checkout
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
fetch-tags: true
fetch-depth: 0
+3 -3
View File
@@ -65,8 +65,8 @@ jobs:
config:
short_name: macos
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
with:
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
python-version: '3.11'
@@ -98,7 +98,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout bonsai_unstable_repo repository
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
repository: IfcOpenShell/bonsai_unstable_repo
token: ${{ secrets.IFCOPENBOT_TOKEN }}
+2 -2
View File
@@ -48,8 +48,8 @@ jobs:
config:
short_name: macos
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
with:
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
python-version: '3.11'
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+2 -2
View File
@@ -37,8 +37,8 @@ jobs:
short_name: macosm164
}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Compile
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Compile
@@ -21,7 +21,7 @@ jobs:
date: ${{ steps.date.outputs.date }}
verdate: ${{ steps.verdate.outputs.verdate }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Set env
run: echo ok go
@@ -75,7 +75,7 @@ jobs:
echo "ARTIFACTS_DIR=/home/runner/work/artifacts" >> $GITHUB_ENV
fi
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
submodules: recursive
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-22.04
needs: activate
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
submodules: recursive
@@ -86,7 +86,7 @@ jobs:
name: Docker Build, Tag, Push
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
lfs: true
@@ -47,10 +47,10 @@ jobs:
short_name: macosm164
}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
submodules: recursive
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
+2 -2
View File
@@ -38,10 +38,10 @@ jobs:
short_name: macosm164
}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
submodules: recursive
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Compile
+2 -2
View File
@@ -25,8 +25,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
with:
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
python-version: '3.11'
+2 -2
View File
@@ -19,8 +19,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
with:
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
python-version: '3.11'
+2 -2
View File
@@ -10,9 +10,9 @@ jobs:
publish_website:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Checkout ifctester_org_static_html
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
repository: IfcOpenShell/ifctester_org_static_html
token: ${{ secrets.IFCOPENBOT_TOKEN }}
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+3 -3
View File
@@ -34,7 +34,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
submodules: recursive
@@ -85,7 +85,7 @@ jobs:
cmake --build build-ifcopenshell --target install -j "$(nproc)"
- name: Set up Python 3.11
uses: actions/setup-python@v7
uses: actions/setup-python@v6
with:
python-version: 3.11
@@ -120,7 +120,7 @@ jobs:
PY
- name: Set up Python 3.12
uses: actions/setup-python@v7
uses: actions/setup-python@v6
with:
python-version: 3.12
+3 -3
View File
@@ -12,15 +12,15 @@ jobs:
MIN_BLENDER_PY_VERSION: "3.11"
steps:
- name: Action - checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Action - install python
uses: actions/setup-python@v7
uses: actions/setup-python@v6
with:
python-version: ${{ env.MIN_IOS_PY_VERSION }}
- name: Action - install python
uses: actions/setup-python@v7
uses: actions/setup-python@v6
with:
python-version: ${{ env.MIN_BLENDER_PY_VERSION }}
@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout IfcOpenShell
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
@@ -25,7 +25,7 @@ jobs:
echo "name=$(basename $WHEEL)" >> $GITHUB_OUTPUT
- name: Checkout wasm-wheels
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
repository: IfcOpenShell/wasm-wheels
path: wasm-wheels
+4 -6
View File
@@ -43,12 +43,12 @@ jobs:
CLICOLOR_FORCE: "1"
CMAKE_COLOR_DIAGNOSTICS: "ON"
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
submodules: recursive
- name: Set up Python
uses: actions/setup-python@v7
uses: actions/setup-python@v6
with:
python-version: 3.11
@@ -257,15 +257,13 @@ jobs:
cd ../ifcdiff && make test || ERROR=1
cd ../ifcpatch && make test || ERROR=1
pip install -e ../ifc5d --no-deps
pip install odfpy openpyxl
pip install odfpy xlsxwriter
cd ../ifc5d && make test || ERROR=1
pip install -e ../ifcquery --no-deps
cd ../ifcquery && make test || ERROR=1
pip install -e ../ifcedit --no-deps
cd ../ifcedit && make test || ERROR=1
# Pinned <2: mcp 2.0.0 renamed mcp.server.fastmcp.FastMCP to
# mcp.server.mcpserver.MCPServer, which ifcmcp doesn't support yet.
pip install "mcp>=1.0,<2"
pip install mcp
pip install -e ../ifcmcp --no-deps
cd ../ifcmcp && make test || ERROR=1
pip install -e ../ifctester --no-deps
@@ -11,10 +11,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v7
uses: actions/setup-python@v6
with:
python-version: '3.x'
+3 -3
View File
@@ -27,12 +27,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout (recursive)
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
submodules: recursive
fetch-depth: 0
- name: Checkout intermediate Pages repo
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
repository: IfcOpenShell/aichat_ifcopenshell_org_static_html
ref: gh-pages
@@ -42,7 +42,7 @@ jobs:
run: |
rsync -av --delete --exclude='.git/' src/ifcchat/ output/
- name: Setup Python
uses: actions/setup-python@v7
uses: actions/setup-python@v6
with:
python-version: "3.x"
- name: Download wheels
@@ -7,7 +7,7 @@ jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v7
@@ -27,12 +27,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout (recursive)
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
submodules: recursive
fetch-depth: 0
- name: Checkout intermediate Pages repo
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
repository: IfcOpenShell/wasm_ifcopenshell_org_static_html
ref: gh-pages
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
submodules: recursive
- name: Install C++ dependencies
+1 -1
View File
@@ -3,7 +3,7 @@
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
<metadata>
<id>blenderbim-nightly</id>
<version>blenderbim_build_version</version>
<version>blenderbim_build_version-alpha</version>
<packageSourceUrl>https://github.com/IfcOpenShell/IfcOpenShell</packageSourceUrl>
<owners>fbpyr</owners>
<!-- == SOFTWARE SPECIFIC SECTION == -->
+11 -14
View File
@@ -3,12 +3,11 @@
apt update && apt install git wget curl ptpython mono-devel micro
mkdir -p /home/runner/work/IfcOpenShell && cd /home/runner/work/IfcOpenShell
git clone https://github.com/IfcOpenShell/IfcOpenShell
cd /home/runner/work/IfcOpenShell/IfcOpenShell/choco/bonsai/
cd /home/runner/work/IfcOpenShell/IfcOpenShell/choco/blenderbim/
micro choco_release.py # paste this script, comment out push command
export CHOCO_TOKEN="secret_choco_release_token"
python3 choco_release.py
"""
import datetime
import hashlib
import os
@@ -29,7 +28,7 @@ def get_repo_tag_names() -> list[str]:
def request_repo_info(url: str):
req = request.Request(url)
req = request.Request(url)
resp = request.urlopen(req)
if not resp.status == 200:
print(f"[ERROR] could not contact server: {url}")
@@ -86,15 +85,13 @@ def run(command: str) -> None:
start = datetime.datetime.now()
URL_CHOCO_PACKAGE = "https://community.chocolatey.org/packages/blender"
URL_BLENDER_CMAKE = (
"https://raw.githubusercontent.com/blender/blender/{}/build_files/cmake/Modules/FindPythonLibsUnix.cmake"
)
RE_BLENDER_VERSION_MIN_MAJ = r"Latest Version.+<span>Blender (\d+\.\d+)\..+</span>"
RE_BLENDER_VERSION_MIN_MAJ_PAT = r"Latest Version.+<span>Blender (\d+\.\d+\.\d+)</span>"
URL_CHOCO_PACKAGE = "https://community.chocolatey.org/packages/blender"
URL_BLENDER_CMAKE = "https://raw.githubusercontent.com/blender/blender/{}/build_files/cmake/Modules/FindPythonLibsUnix.cmake"
RE_BLENDER_VERSION_MIN_MAJ = r"Latest Version.+<span>Blender (\d+\.\d+)\..+</span>"
RE_BLENDER_VERSION_MIN_MAJ_PAT = r"Latest Version.+<span>Blender (\d+\.\d+\.\d+)</span>"
RE_BLENDER_PYTHON_VERSION_MAJ_MIN = r"\(_PYTHON_VERSION_SUPPORTED (\d+\.\d+)\)"
BLENDERBIM_DIR = pathlib.Path("/home/runner/work/IfcOpenShell/IfcOpenShell/choco/bonsai/")
BLENDERBIM_DIR = pathlib.Path("/home/runner/work/IfcOpenShell/IfcOpenShell/choco/blenderbim/")
print("_____ check choco release needed?")
@@ -151,7 +148,7 @@ print(f"{blender_python_version_maj_min=}")
python_version = f"py{found[0].replace('.', '')}"
print(f"{python_version=}")
blenderbim_build_version = target_release_tag.replace("bonsai-", "")
blenderbim_build_version = target_release_tag.replace("blenderbim-", "")
# url_blenderbim_py3x_win_zip
release_zip_file_name, url_blenderbim_py3x_win_zip = get_release_zip(target_release_tag)
@@ -169,15 +166,15 @@ topics = {
"path": HERE_DIR / "blenderbim.nuspec",
"key_values": {
"latest_blender_version_maj_min_pat": latest_blender_release_maj_min_pat,
"blenderbim_build_version": blenderbim_build_version,
"blenderbim_build_version" : blenderbim_build_version,
},
},
"install": {
"path": HERE_DIR / "tools" / "chocolateyinstall.ps1",
"key_values": {
"url_blenderbim_py3x_win_zip": url_blenderbim_py3x_win_zip,
"url_blenderbim_py3x_win_zip" : url_blenderbim_py3x_win_zip,
"sha256sum_blenderbim_py3x_win_zip": sha256sum_blenderbim_py3x_win_zip,
"latest_blender_version_maj_min": blender_version_min_maj,
"latest_blender_version_maj_min" : blender_version_min_maj,
},
},
"uninstall": {
+2 -5
View File
@@ -121,7 +121,7 @@ import tarfile
import threading
from datetime import datetime
ssl._create_default_https_context = ssl._create_unverified_context # ty:ignore[invalid-assignment]
ssl._create_default_https_context = ssl._create_unverified_context
import time
from collections.abc import Generator, Sequence
@@ -697,10 +697,7 @@ def build_dependency(
compr = "xz"
else:
raise RuntimeError("fix source for new download type")
# ty: false positive bug upstream.
download_tarfile = tarfile.open(
name=download_tarfile_path, mode=f"r:{compr}"
) # ty:ignore[no-matching-overload]
download_tarfile = tarfile.open(name=download_tarfile_path, mode=f"r:{compr}")
# tarfile seriously doesn't have a function to retrieve the root directory more easily
extract_dir_name = os.path.commonprefix([x for x in download_tarfile.getnames() if x != "."])
# run([tar, "--exclude=\"*/*\"", "-tf", download_name], cwd=build_dir).strip() no longer works
+1 -2
View File
@@ -156,8 +156,7 @@ exclude = [
[tool.poe.tasks]
dev-setup.sequence = [
# 3.13 is chosen because it's the version used in the latest Bonsai.
{cmd = "uv sync --python 3.13"},
{cmd = "uv sync"},
{cmd = "uv pip install -e ./src/bsdd/"},
{cmd = "uv pip install -e ./src/ifcopenshell-python/[advanced,dev]"},
{cmd = "uv pip install -e ./src/ifcedit/"},
+3 -3
View File
@@ -1,5 +1,5 @@
black==26.3.1
ruff==0.16.0
ruff==0.15.22
poethepoet
ty==0.0.63
gersemi==0.28.0
ty==0.0.61
gersemi==0.26.1
@@ -5,7 +5,7 @@ FILE_NAME('Psets_BBIM_Annotation.ifc','2020-01-01T00:00:00',$,$,'Psets_BBIM_Anno
FILE_SCHEMA(('IFC4'));
ENDSEC;
DATA;
#1=IFCPROPERTYSETTEMPLATE('3VuPUwdCD2Qx3XDDRs0R1N',$,'EPset_Annotation','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation,IfcTypeProduct',(#4,#33,#29,#32,#3,#2,#41,#42));
#1=IFCPROPERTYSETTEMPLATE('3VuPUwdCD2Qx3XDDRs0R1N',$,'EPset_Annotation','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation,IfcTypeProduct',(#4,#33,#29,#32,#3,#2));
#2=IFCSIMPLEPROPERTYTEMPLATE('2P7JN79n96Q9pElZ83LKe4',$,'ZIndex','',.P_SINGLEVALUE.,'IfcInteger',$,$,$,$,$,.READWRITE.);
#3=IFCSIMPLEPROPERTYTEMPLATE('1Wpx_r2xj1_9w5JpI0QRJy',$,'Symbol','',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#4=IFCSIMPLEPROPERTYTEMPLATE('3q0oxMUKP47vZ4jnyG$dDb',$,'Classes','Classes separated by spaces that end up in classes for this element in svg. Can be used to specify the text font size: small - 1.8mm; regular - 2.5mm; large - 3.5mm; header - 5mm; title - 7mm. By default regular size is used.',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
@@ -28,7 +28,7 @@ DATA;
#21=IFCSIMPLEPROPERTYTEMPLATE('1UDakJ5_f7kBhggNSW4$h5',$,'SymbolsPath','Default symbols SVG',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#22=IFCSIMPLEPROPERTYTEMPLATE('0d53LEtgLDQxnv__NfgH7i',$,'PatternsPath','Default patterns SVG',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#23=IFCSIMPLEPROPERTYTEMPLATE('26qFNMv7nCHgU6Jd7Anga5',$,'ShadingStylesPath','Default shading styles',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#24=IFCPROPERTYSETTEMPLATE('0I9merLinF5Ap$aZwaclgm',$,'BBIM_Dimension','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation/DIMENSION,IfcAnnotation/RADIUS,IfcAnnotation/DIAMETER,IfcAnnotation/ANGLE,IfcAnnotation/PLAN_LEVEL,IfcAnnotation/SECTION_LEVEL,IfcTypeProduct',(#25,#26,#35,#36,#27,#28,#30,#34,#37,#38,#39,#40));
#24=IFCPROPERTYSETTEMPLATE('0I9merLinF5Ap$aZwaclgm',$,'BBIM_Dimension','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation/DIMENSION,IfcAnnotation/RADIUS,IfcAnnotation/DIAMETER,IfcTypeProduct',(#25,#26,#27,#28,#30));
#25=IFCSIMPLEPROPERTYTEMPLATE('1rL2AbQsXD8RbpoWH5pYOV',$,'ShowDescriptionOnly','Hide the measurement values and show only annotation description',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#26=IFCSIMPLEPROPERTYTEMPLATE('0SVyOfB0rC2xNfdRYf3XvY',$,'SuppressZeroInches','Suppress 0 inch values in dimension annotation text (for example: 12'' - 0" -> 12'')',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#27=IFCSIMPLEPROPERTYTEMPLATE('2bUmj458PBqPAtUoI3MXsb',$,'TextPrefix','Text to add before annotation measurement value',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
@@ -38,14 +38,5 @@ DATA;
#31=IFCPROPERTYENUMERATION('CustomUnit',(IFCTEXT('Feet and Inches - Fractional'),IFCTEXT('Feet - Decimal'),IFCTEXT('Inches - Fractional'),IFCTEXT('Inches - Decimal'),IFCTEXT('Meters'),IFCTEXT('Decimeters'),IFCTEXT('Centimeters'),IFCTEXT('Millimeters')),$);
#32=IFCSIMPLEPROPERTYTEMPLATE('0gjJzDYBX8P85qn1xcAOOo',$,'Reverse_List','',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#33=IFCSIMPLEPROPERTYTEMPLATE('22TrcxF8jFNB4buSmzjGEF',$,'List_Separator','',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
#34=IFCSIMPLEPROPERTYTEMPLATE('1Kx4Pm9nR8vBwZqTs2uYeL',$,'Separator','Characters placed between multiple dimension values when CustomUnit has more than one unit selected (default: '' / '')',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#35=IFCSIMPLEPROPERTYTEMPLATE('3Nf6Qs1mT0pWxBuCvDyEzA',$,'SuppressZeroFeet','Suppress 0 feet in dimension annotation text (for example: 0'' - 3 1/2" -> 3 1/2")',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#36=IFCSIMPLEPROPERTYTEMPLATE('2Rg7Hn5jK4mLpNqOsVwXtY',$,'IsOrdinate','Show accumulated distance from the first vertex instead of individual segment lengths',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#37=IFCSIMPLEPROPERTYTEMPLATE('1XpRnKoT2sGuW7vYcZaMqb',$,'Anchors','JSON array of parametric anchor descriptors — one per polyline vertex. Each entry: {"guid": str|null, "type": "FACE"|"CIRCLE_CENTER"|"WORLD", "addr": {...}, "hint": [x,y,z]|null, "pt": [x,y,z]}',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
#38=IFCSIMPLEPROPERTYTEMPLATE('2YqSmLoU3tHvX8wZdaNrjc',$,'MeasureAxis','Axis along which distances are projected: X | Y | Z | TRUE | PERPENDICULAR',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#39=IFCSIMPLEPROPERTYTEMPLATE('3Ny31Go6T5Z9fh8j4yQC0p',$,'ForcePerpendicularToFace','When enabled the polyline is constrained to follow the face normal of the first anchor vertex so the dimension measures straight-line distance perpendicular to that face',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#40=IFCSIMPLEPROPERTYTEMPLATE('1LoNpKqR3sTuVwXyZaBcDe',$,'LinePosition','Absolute world-space coordinate (metres) of the dimension line along the horizontal offset axis (perpendicular to the dimension direction). When set, the dimension line is held at this fixed global position even if the measured geometry moves. When absent the line sits at the anchor points.',.P_SINGLEVALUE.,'IfcLengthMeasure',$,$,$,$,$,.READWRITE.);
#41=IFCSIMPLEPROPERTYTEMPLATE('0FauxIsAnnotFaux0001aB',$,'IsManualDrawingReference','Marks this annotation as a manually placed drawing reference, exempt from automatic drawing regeneration.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#42=IFCSIMPLEPROPERTYTEMPLATE('0FauxIsDocRefFaux001aB',$,'IsDocumentReference','Marks this annotation as pointing to an external document reference (not a Bonsai drawing camera).',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
ENDSEC;
END-ISO-10303-21;
+6 -52
View File
@@ -10,7 +10,6 @@ if bonsai_lib_path:
import argparse
import base64
import json
import urllib.parse
import xml.etree.ElementTree as ET
import pystache
@@ -19,53 +18,8 @@ from aiohttp import web
sio_port = 8080 # default port
def get_asset_version() -> str:
"""A cache-busting token appended to locally served static asset URLs.
Browsers otherwise keep serving a stale cached copy of static/js and
static/css after Bonsai ships a code change, until the user does a hard
refresh. Using the Bonsai version (which includes the build's commit
hash) means the token changes on every shipped update.
"""
if bonsai_version:
return urllib.parse.quote(bonsai_version, safe="")
# Fallback for standalone runs without BONSAI_VERSION set (e.g. running
# sioserver.py directly outside of Blender): derive a token from the
# newest mtime among the static assets, so it still changes whenever the
# shipped files change.
static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
latest_mtime = 0
for root, _dirs, files in os.walk(static_dir):
for name in files:
latest_mtime = max(latest_mtime, int(os.path.getmtime(os.path.join(root, name))))
return f"dev-{latest_mtime}"
asset_version = get_asset_version()
@web.middleware
async def no_cache_static_middleware(request: web.Request, handler):
"""Force revalidation of locally served static assets.
Query-string version stamping (see `asset_version`) busts the cache for
the HTML-referenced entry points, but JS files that statically import
other local modules (e.g. cost.js/gantt.js importing utilities/costui.js)
reference those modules by an un-stamped relative path. Marking all
/static/ and /jsgantt/ responses as no-cache makes browsers always
revalidate with the server (a cheap conditional GET / 304 when nothing
changed), so nested imports also pick up shipped changes without
requiring a hard refresh.
"""
response = await handler(request)
if request.path.startswith("/static/") or request.path.startswith("/jsgantt/"):
response.headers["Cache-Control"] = "no-cache, must-revalidate"
return response
sio = socketio.AsyncServer(cors_allowed_origins="*", async_mode="aiohttp", max_http_buffer_size=10000000)
app = web.Application(middlewares=[no_cache_static_middleware])
app = web.Application()
sio.attach(app)
@@ -245,28 +199,28 @@ class BlenderNamespace(socketio.AsyncNamespace):
async def schedules(request):
with open("templates/index.html", "r") as f:
template = f.read()
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version})
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version})
return web.Response(text=html_content, content_type="text/html")
async def costing(request):
with open("templates/costing.html", "r") as f:
template = f.read()
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version})
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version})
return web.Response(text=html_content, content_type="text/html")
async def sequencing(request):
with open("templates/gantt.html", "r") as f:
template = f.read()
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version})
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version})
return web.Response(text=html_content, content_type="text/html")
async def documentation(request):
with open("templates/drawings.html", "r") as f:
template = f.read()
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version})
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version})
return web.Response(text=html_content, content_type="text/html")
@@ -275,7 +229,7 @@ async def documentation(request):
async def demo(request):
with open("templates/demo.html", "r") as f:
template = f.read()
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version})
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version})
return web.Response(text=html_content, content_type="text/html")
@@ -250,14 +250,6 @@ export class CostUI {
},
});
CostUI.addRibbonButton({
text: "Download CSV",
icon: "fa-solid fa-file-csv",
callback: () => {
CostUI.downloadCsv();
},
});
CostUI.addRibbonButton({
text: "Hide Schedules",
icon: "fa-regular fa-eye-slash",
@@ -531,81 +523,6 @@ export class CostUI {
}
}
static downloadCsv() {
const tables = document.querySelectorAll("table[id^='cost-items-']");
if (tables.length === 0) {
alert("No cost schedule loaded to export!");
return;
}
tables.forEach((table) => {
const scheduleId = table.id.split("-").pop();
const csv = CostUI.tableToCsv(table);
if (csv === null) {
return;
}
const nameEl = document.querySelector(
"#cost-schedule-container-" + scheduleId + " .form-header span"
);
const scheduleName = nameEl
? nameEl.textContent
: "cost_schedule_" + scheduleId;
CostUI.triggerCsvDownload(csv, scheduleName + ".csv");
});
}
static tableToCsv(table) {
const escapeCsvCell = (value) => {
const text = (value === null || value === undefined ? "" : value)
.toString()
.trim();
if (/[",\n]/.test(text)) {
return '"' + text.replace(/"/g, '""') + '"';
}
return text;
};
const cellText = (cell) => {
const input = cell.querySelector("input");
return input ? input.value : cell.innerText;
};
// The Actions column only holds buttons (edit/delete/etc), not data.
const isDataColumn = (column) => column && column !== "Actions";
const headerCells = Array.from(table.querySelectorAll("thead th")).filter(
(th) => isDataColumn(th.getAttribute("data-column"))
);
if (headerCells.length === 0) {
return null;
}
const rows = [headerCells.map((th) => escapeCsvCell(th.textContent)).join(",")];
table.querySelectorAll("tbody tr").forEach((row) => {
const cells = Array.from(row.children).filter((cell) =>
isDataColumn(cell.getAttribute("data-column"))
);
if (cells.length === 0) {
return; // e.g. the "No cost items found" placeholder row.
}
rows.push(cells.map((cell) => escapeCsvCell(cellText(cell))).join(","));
});
return rows.join("\n");
}
static triggerCsvDownload(csvContent, filename) {
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
static createCostTable({ costSchedule, currency, callbacks }) {
const preferences = CostUI.getColumnPreferences();
const isScheduleOfRates = costSchedule.PredefinedType === "SCHEDULEOFRATES";
@@ -7,7 +7,7 @@
<link
rel="stylesheet"
type="text/css"
href="/static/css/cost.css?v={{v}}"
href="/static/css/cost.css"
id="index-stylesheet"
/>
<link
@@ -21,7 +21,7 @@
/>
<script
type="text/javascript"
src="/static/js/jquery.min.js?v={{v}}"
src="/static/js/jquery.min.js"
></script>
<script
type="text/javascript"
@@ -34,7 +34,7 @@
<script>
var SOCKET_PORT = {{port}};
</script>
<script type="module" defer src="./static/js/cost.js?v={{v}}"></script>
<script type="module" defer src="./static/js/cost.js"></script>
</head>
<body>
<nav>
@@ -12,14 +12,14 @@
/>
<!-- here we request the CSS file from the server, -->
<!-- using registered static path in the server -->
<link rel="stylesheet" href="/static/css/demo.css?v={{v}}" id="demo-stylesheet" />
<link rel="stylesheet" href="/static/css/demo.css" id="demo-stylesheet" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css"
/>
<script
type="text/javascript"
src="/static/js/jquery.min.js?v={{v}}"
src="/static/js/jquery.min.js"
></script>
<script
type="text/javascript"
@@ -33,7 +33,7 @@
</script>
<!-- here we request the JS file from the server-->
<!-- using registered static path in the server -->
<script defer src="./static/js/demo.js?v={{v}}"></script>
<script defer src="./static/js/demo.js"></script>
</head>
<body>
<!-- the navigation bar at the top of the page. -->
@@ -15,7 +15,7 @@
/>
<link
rel="stylesheet"
href="/static/css/drawings.css?v={{v}}"
href="/static/css/drawings.css"
id="drawings-stylesheet"
/>
<link
@@ -24,7 +24,7 @@
/>
<script
type="text/javascript"
src="/static/js/jquery.min.js?v={{v}}"
src="/static/js/jquery.min.js"
></script>
<script
type="text/javascript"
@@ -45,7 +45,7 @@
<script>
var SOCKET_PORT = {{port}};
</script>
<script defer src="./static/js/drawings.js?v={{v}}"></script>
<script defer src="./static/js/drawings.js"></script>
</head>
<body>
<nav>
@@ -9,25 +9,25 @@
type="image/x-icon"
href="https://bonsaibim.org/assets/images/favicon-blender.png"
/>
<link rel="stylesheet" type="text/css" href="/jsgantt/jsgantt.css?v={{v}}" />
<link rel="stylesheet" href="/static/css/gantt.css?v={{v}}" id="gantt-stylesheet" />
<link rel="stylesheet" type="text/css" href="/jsgantt/jsgantt.css" />
<link rel="stylesheet" href="/static/css/gantt.css" id="gantt-stylesheet" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css"
/>
<script
type="text/javascript"
src="/static/js/jquery.min.js?v={{v}}"
src="/static/js/jquery.min.js"
></script>
<script
type="text/javascript"
src="https://cdn.socket.io/4.0.0/socket.io.min.js"
></script>
<script type="text/javascript" src="./jsgantt/jsgantt.js?v={{v}}"></script>
<script type="text/javascript" src="./jsgantt/jsgantt.js"></script>
<script>
var SOCKET_PORT = {{port}};
</script>
<script type="module" defer src="./static/js/gantt.js?v={{v}}"></script>
<script type="module" defer src="./static/js/gantt.js"></script>
</head>
<body>
<nav class="no-print">
@@ -9,7 +9,7 @@
type="image/x-icon"
href="https://bonsaibim.org/assets/images/favicon-blender.png"
/>
<link rel="stylesheet" href="/static/css/index.css?v={{v}}" id="index-stylesheet" />
<link rel="stylesheet" href="/static/css/index.css" id="index-stylesheet" />
<link
rel="stylesheet"
id="tabulator-stylesheet"
@@ -21,7 +21,7 @@
/>
<script
type="text/javascript"
src="/static/js/jquery.min.js?v={{v}}"
src="/static/js/jquery.min.js"
></script>
<script
type="text/javascript"
@@ -34,7 +34,7 @@
<script>
var SOCKET_PORT = {{port}};
</script>
<script defer src="./static/js/index.js?v={{v}}"></script>
<script defer src="./static/js/index.js"></script>
</head>
<body>
<nav>
+3 -4
View File
@@ -418,10 +418,9 @@ def get_user(ifc: ifcopenshell.file) -> Union[ifcopenshell.entity_instance, None
def viewport_shading_changed_callback(area: bpy.types.Area) -> None:
shading_type = area.spaces.active.shading.type
tool.Style.restore_material_style_types(shading_type)
if shading_type == "SOLID":
area.spaces.active.shading.color_type = "MATERIAL"
shading = area.spaces.active.shading.type
if shading == "RENDERED":
tool.Style.get_style_props().active_style_type = "External"
def subscribe_to_viewport_shading_changes():
+4
View File
@@ -133,6 +133,10 @@ class MaterialCreator:
if shape_has_openings and coords.is_a("IfcIndexedTextureMap"):
continue
tool.Loader.load_indexed_map(coords, self.mesh)
elif tool.Style.get_texture_style(material):
# No explicit coordinate mapping (e.g. IFC2X3 has no IsMappedBy,
# and IFC4 COORD uses generated UVs). Bake XY→UV as fallback.
tool.Loader.load_generated_uv_map(self.mesh)
def assign_material_slots_to_faces(self) -> None:
if not self.mesh["ios_materials"]:
@@ -32,7 +32,6 @@ classes = (
operator.ActivateModel,
operator.AddAnnotation,
operator.AddAnnotationType,
operator.AddElevationAnnotation,
operator.AddDrawing,
operator.AddDrawingStyle,
operator.AddDrawingToSheet,
@@ -109,19 +108,8 @@ classes = (
operator.SelectAssignedProduct,
operator.SelectSimilarTextLiteralValue,
operator.ToggleTargetView,
operator.ToggleDrawingCategorySelection,
operator.OpenDocumentationWebUi,
operator.FilterSelectedObjectsIfIntersectedByCamera,
operator.DrawParametricDimension,
operator.SetDimensionAnchor,
operator.RegenerateDimensions,
operator.DriveDimensionLength,
operator.RemoveDimensionAnchor,
operator.InsertDimensionAnchor,
operator.ClickNearestDimensionAnchor,
operator.MakeDimensionParametric,
operator.BakeParametricDimension,
operator.DebugDimensionClicks,
prop.Variable,
prop.Drawing,
prop.Document,
@@ -183,19 +171,11 @@ classes = (
gizmos.UglyDotGizmo,
gizmos.ExtrusionGuidesGizmo,
gizmos.ExtrusionWidget,
gizmos.GizmoAnchorHandle,
gizmos.GizmoDriveDimLabel,
gizmos.DimensionAnchorWidget,
gizmos.DimensionLinePositionWidget,
gizmos.DimensionDriveLabelWidget,
workspace.LaunchAnnotationTypeManager,
workspace.Hotkey,
)
_keymaps = []
def menu_func(self, context):
active_obj = context.active_object
if active_obj:
@@ -215,21 +195,9 @@ def register():
bpy.types.TextCurve.BIMTextProperties = bpy.props.PointerProperty(type=prop.BIMTextProperties)
bpy.app.handlers.load_post.append(handler.load_post)
bpy.app.handlers.depsgraph_update_pre.append(handler.depsgraph_update_pre_handler)
bpy.app.handlers.depsgraph_update_post.append(handler.depsgraph_update_post_handler)
bpy.types.VIEW3D_MT_image_add.append(ui.add_object_button)
bpy.types.VIEW3D_MT_object_context_menu.append(menu_func)
wm = bpy.context.window_manager
kc = wm.keyconfigs.addon
if kc:
km = kc.keymaps.new(name="3D View", space_type="VIEW_3D")
kmi = km.keymap_items.new("bim.click_nearest_dimension_anchor", "LEFTMOUSE", "PRESS")
_keymaps.append((km, kmi))
kmi_alt = km.keymap_items.new("bim.click_nearest_dimension_anchor", "LEFTMOUSE", "PRESS", alt=True)
_keymaps.append((km, kmi_alt))
kmi_ctrl = km.keymap_items.new("bim.click_nearest_dimension_anchor", "LEFTMOUSE", "PRESS", ctrl=True)
_keymaps.append((km, kmi_ctrl))
def unregister():
if not bpy.app.background:
@@ -242,10 +210,5 @@ def unregister():
del bpy.types.TextCurve.BIMTextProperties
bpy.app.handlers.load_post.remove(handler.load_post)
bpy.app.handlers.depsgraph_update_pre.remove(handler.depsgraph_update_pre_handler)
bpy.app.handlers.depsgraph_update_post.remove(handler.depsgraph_update_post_handler)
for km, kmi in _keymaps:
km.keymap_items.remove(kmi)
_keymaps.clear()
bpy.types.VIEW3D_MT_image_add.remove(ui.add_object_button)
bpy.types.VIEW3D_MT_object_context_menu.remove(menu_func)
+3 -22
View File
@@ -55,14 +55,6 @@ class ProductAssignmentsData:
element = tool.Ifc.get_entity(bpy.context.active_object)
if not element or not element.is_a("IfcAnnotation"):
return
# Document-reference annotations link to an IfcDocumentInformation, not a product.
if tool.Drawing.is_document_reference(element):
for rel in element.HasAssociations:
if rel.is_a("IfcRelAssociatesDocument"):
doc = rel.RelatingDocument
if doc.is_a("IfcDocumentInformation"):
return doc.Name or "Unnamed"
return None
for rel in element.HasAssignments:
if rel.is_a("IfcRelAssignsToProduct"):
name = rel.RelatingProduct.Name or "Unnamed"
@@ -320,9 +312,6 @@ class DecoratorData:
"StartArrowSymbol": "",
"ShowEndArrow": True,
"EndArrowSymbol": "",
"BorderOffset": 8.0,
"AutoStartPosition": "",
"AutoEndPosition": "",
}
obj_pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Section") or {}
pset_data.update(obj_pset_data)
@@ -342,9 +331,6 @@ class DecoratorData:
"symbol": end_symbol or "section-arrow",
},
"connect_markers": pset_data["HasConnectedSectionLine"],
"border_offset": float(pset_data["BorderOffset"]),
"auto_start_position": pset_data["AutoStartPosition"] or "",
"auto_end_position": pset_data["AutoEndPosition"] or "",
}
cls.data[obj.name] = display_data
@@ -813,24 +799,19 @@ class DecoratorData:
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension") or {}
show_description_only = pset_data.get("ShowDescriptionOnly", False)
suppress_zero_inches = pset_data.get("SuppressZeroInches", False)
suppress_zero_feet = pset_data.get("SuppressZeroFeet", False)
is_ordinate = pset_data.get("IsOrdinate", False)
text_prefix = pset_data.get("TextPrefix", None) or ""
text_suffix = pset_data.get("TextSuffix", None) or ""
custom_units = list(pset_data.get("CustomUnit", None) or [])
separator = pset_data.get("Separator", None) or " / "
custom_unit_list = pset_data.get("CustomUnit", None) or ""
custom_unit = custom_unit_list[0] if custom_unit_list else ""
return {
"dimension_style": dimension_style,
"show_description_only": show_description_only,
"suppress_zero_inches": suppress_zero_inches,
"suppress_zero_feet": suppress_zero_feet,
"is_ordinate": is_ordinate,
"text_prefix": text_prefix,
"text_suffix": text_suffix,
"fill_bg": fill_bg,
"custom_units": custom_units,
"separator": separator,
"custom_unit": custom_unit,
}
@classmethod
@@ -494,7 +494,7 @@ class BaseDecorator:
self.draw_label(context, text=text, line_no=line_number_start, multiline=True, **draw_label_kwargs)
@cache
def format_value(self, context, value, suppress_zero_inches=False, suppress_zero_feet=False, custom_unit=None, in_unit_length=False):
def format_value(self, context, value, suppress_zero_inches=False, custom_unit=None, in_unit_length=False):
drawing_pset_data = DrawingsData.data["active_drawing_pset_data"]
precision = drawing_pset_data.get("MetricPrecision", None)
if not precision:
@@ -506,7 +506,6 @@ class BaseDecorator:
precision=precision,
decimal_places=decimal_places,
suppress_zero_inches=suppress_zero_inches,
suppress_zero_feet=suppress_zero_feet,
custom_unit=custom_unit,
in_unit_length=in_unit_length,
)
@@ -723,13 +722,11 @@ class DimensionDecorator(BaseDecorator):
if not dimension_data:
return
show_description_only = dimension_data["show_description_only"]
is_ordinate = dimension_data["is_ordinate"]
text_prefix = dimension_data["text_prefix"]
text_suffix = dimension_data["text_suffix"]
viewportDrawingScale = self.get_viewport_drawing_scale(context)
text_offset_value = viewportDrawingScale * 3
ordinate_total = 0.0
for i0, i1 in indices:
v0 = Vector(vertices[i0])
v1 = Vector(vertices[i1])
@@ -748,25 +745,16 @@ class DimensionDecorator(BaseDecorator):
"multiline": True,
"text_dir": text_dir,
}
base_pos = p1 if is_ordinate else p0 + text_dir * 0.5
base_pos = p0 + text_dir * 0.5
if not show_description_only:
segment_length = (v1 - v0).length
if is_ordinate:
ordinate_total += segment_length
length = ordinate_total if is_ordinate else segment_length
units_to_format = dimension_data["custom_units"] if dimension_data["custom_units"] else [None]
parts = [
self.format_value(
context,
length,
suppress_zero_inches=dimension_data["suppress_zero_inches"],
suppress_zero_feet=dimension_data["suppress_zero_feet"],
custom_unit=unit,
)
for unit in units_to_format
]
text = dimension_data["separator"].join(str(p) for p in parts)
length = (v1 - v0).length
text = self.format_value(
context,
length,
suppress_zero_inches=dimension_data["suppress_zero_inches"],
custom_unit=dimension_data["custom_unit"],
)
if isinstance(self, DiameterDecorator):
text = "D" + text
text = text_prefix + text + text_suffix
@@ -777,18 +765,15 @@ class DimensionDecorator(BaseDecorator):
self.draw_label(
text=text,
pos=base_pos + text_offset + (Vector((0, text_offset_value)) if is_ordinate else Vector((0, 0))),
box_alignment="bottom-right" if is_ordinate else "bottom-middle",
pos=base_pos + text_offset,
box_alignment="bottom-middle",
multiline_to_bottom=False,
**common_label_attrs,
)
if not show_description_only and description:
self.draw_label(
text=description,
pos=base_pos - text_offset + (Vector((0, text_offset_value)) if is_ordinate else Vector((0, 0))),
box_alignment="top-right" if is_ordinate else "top-middle",
**common_label_attrs,
text=description, pos=base_pos - text_offset, box_alignment="top-middle", **common_label_attrs
)
@@ -984,9 +969,7 @@ class RadiusDecorator(BaseDecorator):
def get_text():
length = (spline_points[-1] - spline_points[-2]).length
units_to_format = dimension_data["custom_units"] if dimension_data["custom_units"] else [None]
parts = [self.format_value(context, length, suppress_zero_feet=dimension_data["suppress_zero_feet"], custom_unit=unit) for unit in units_to_format]
return "R" + dimension_data["separator"].join(str(p) for p in parts)
return "R" + self.format_value(context, length, custom_unit=dimension_data["custom_unit"])
self.draw_dimension_text(
context, get_text, description, dimension_data, pos=pos, text_dir=Vector((1, 0)), box_alignment="center"
@@ -1522,20 +1505,6 @@ class ElevationDecorator(BaseDecorator):
"output_edges": output_edges,
}
# Determine the arrow direction in camera-image-plane (XY) space.
# The elevation tag's local -Z is intentionally parallel to the drawing
# camera's view direction, so projecting it always gives a near-zero XY
# delta. Fall through to local +X (which is perpendicular to the view
# and rotates visibly when the user spins the tag).
view_mat = context.region_data.view_matrix
edge_dir_2d = Vector((1.0, 0.0)) # final fallback
for local_axis in (Vector((0, 0, -1)), Vector((1, 0, 0)), Vector((0, 1, 0))):
world_axis = obj.matrix_world.to_3x3() @ local_axis
cam_xy = (view_mat.to_3x3() @ world_axis).xy
if cam_xy.length > 1e-6:
edge_dir_2d = cam_xy.normalized()
break
# process edges
for edge in edges_original:
v0, v1 = winspace_verts[edge[0]], winspace_verts[edge[1]]
@@ -1544,7 +1513,7 @@ class ElevationDecorator(BaseDecorator):
circle_head = get_circle_head(circle_size)
start_i = add_verts_sequence(add_offsets(v0, circle_head), start_i, **out_kwargs, closed=True)
edge_dir = edge_dir_2d.to_3d()
edge_dir = (v1 - v0).normalized()
side = (edge_dir.yx * Vector((1, -1))).to_3d()
triangle_head = get_triangle_head(side, edge_dir, triangle_length, triangle_width)
start_i = add_verts_sequence(add_offsets(v0, triangle_head), start_i, **out_kwargs, closed=True)
@@ -2160,8 +2129,4 @@ class DecorationsHandler:
object_decorators = DecoratorData.data.get("object_decorators", [])
for obj, decorator in object_decorators:
try:
decorator.decorate(context, obj)
except ReferenceError:
DecoratorData.is_loaded = False
break
decorator.decorate(context, obj)
@@ -2297,19 +2297,6 @@ DISC = (
(1.0, 0.0, 0),
)
# Anchor index currently being edited by SetDimensionAnchor (-1 = none).
_active_anchor_idx: int = -1
# The annotation curve object being edited (kept so the gizmo group stays
# visible even when SetDimensionAnchor temporarily changes the active object).
_editing_annotation_obj = None
def set_active_anchor(idx: int, annotation_obj=None) -> None:
global _active_anchor_idx, _editing_annotation_obj
_active_anchor_idx = idx
_editing_annotation_obj = annotation_obj if idx >= 0 else None
X3DISC = (
(0.0, 0.0, 0.0),
(1.0, 0.0, 0),
@@ -2634,370 +2621,6 @@ class ExtrusionWidget(types.GizmoGroup):
self.handle.target_set_prop("offset", prop, "value")
self.guides.target_set_prop("depth", prop, "value")
class GizmoAnchorHandle(bpy.types.Gizmo):
"""Visual-only dot at a parametric dimension vertex.
No draw_select/invoke draw_select puts the gizmo in Blender's select buffer
and causes the gizmo system to consume clicks even without an explicit invoke,
blocking ClickNearestDimensionAnchor from receiving them. All click handling
is done by the bim.click_nearest_dimension_anchor keymap operator.
"""
bl_idname = "BIM_GT_anchor_handle"
__slots__ = ("anchor_index", "custom_shape")
def setup(self):
self.anchor_index = 0
self.custom_shape = self.new_custom_shape(type="TRIS", verts=X3DISC)
def draw(self, context):
self.draw_custom_shape(self.custom_shape)
class DimensionAnchorWidget(types.GizmoGroup):
"""Anchor handle gizmos at each vertex of the active parametric dimension.
Green dots indicate vertices that are anchored to an IFC element face;
orange dots are free world-point anchors. Clicking any dot fires
``bim.set_dimension_anchor`` pre-targeted at that vertex index.
"""
bl_idname = "BIM_GGT_dimension_anchors"
bl_label = "Dimension Anchor Handles"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT", "SHOW_MODAL_ALL"}
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"))
_MAX_ANCHORS = 16
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not tool.Ifc.get():
return False
# Stay visible while SetDimensionAnchor is running (active obj may temporarily
# be an IFC element in the face-picking phase rather than the annotation).
if _active_anchor_idx >= 0 and _editing_annotation_obj is not None:
active = context.active_object
if active is _editing_annotation_obj:
return True # annotation still active
if active is not None and tool.Ifc.get_entity(active) is not None:
return True # face-picking phase: active obj is a target element
# Active object is None or a non-IFC object — the modal ended without
# calling set_active_anchor(-1). Reset stale state and fall through.
set_active_anchor(-1)
obj = context.active_object
if not obj or obj.type != "CURVE":
return False
if not obj.select_get():
return False
element = tool.Ifc.get_entity(obj)
if not element or not element.is_a("IfcAnnotation"):
return False
import ifcopenshell.util.element as _ue
if _ue.get_predefined_type(element) not in cls._DIM_TYPES:
return False
pset = _ue.get_pset(element, "BBIM_Dimension")
return bool(pset and pset.get("Anchors"))
def setup(self, context: bpy.types.Context) -> None:
self._handles: list = []
for _ in range(self._MAX_ANCHORS):
gz = self.gizmos.new("BIM_GT_anchor_handle")
gz.scale_basis = 0.2
gz.use_draw_modal = True
gz.hide = True
self._handles.append(gz)
def refresh(self, context: bpy.types.Context) -> None:
import json
import ifcopenshell.util.element as _ue
obj = _editing_annotation_obj if _active_anchor_idx >= 0 and _editing_annotation_obj else context.active_object
if not obj or not obj.data or not getattr(obj.data, "splines", None):
for gz in self._handles:
gz.hide = True
return
element = tool.Ifc.get_entity(obj)
if not element:
for gz in self._handles:
gz.hide = True
return
pset = _ue.get_pset(element, "BBIM_Dimension")
if not pset or not pset.get("Anchors"):
for gz in self._handles:
gz.hide = True
return
try:
anchors = json.loads(pset["Anchors"])
except Exception:
for gz in self._handles:
gz.hide = True
return
spline = obj.data.splines[0]
n = min(len(spline.points), len(anchors), self._MAX_ANCHORS)
import ifcopenshell.util.element as _ue_gz
_ptype_gz = _ue_gz.get_predefined_type(element)
_is_elevation_gz = _ptype_gz in ("SECTION_LEVEL", "PLAN_LEVEL")
for i in range(n):
gz = self._handles[i]
if _is_elevation_gz:
# The object origin IS the anchor reference point (placed at face hit).
# Spline vertices are offset from the origin and should not be used.
world_co = obj.matrix_world.translation.copy()
else:
raw_co = spline.points[i].co
world_co = obj.matrix_world @ raw_co.to_3d()
gz.matrix_basis = Matrix.Translation(world_co)
gz.anchor_index = i
if i == _active_anchor_idx and obj is _editing_annotation_obj:
gz.color = (0.2, 0.7, 1.0)
gz.color_highlight = (0.4, 0.85, 1.0)
elif anchors[i].get("guid"):
gz.color = (0.2, 0.85, 0.2)
gz.color_highlight = (0.4, 1.0, 0.4)
else:
gz.color = (0.9, 0.6, 0.1)
gz.color_highlight = (1.0, 0.85, 0.2)
gz.alpha = 0.85
gz.alpha_highlight = 1.0
gz.hide = False
for i in range(n, self._MAX_ANCHORS):
self._handles[i].hide = True
def draw_prepare(self, context: bpy.types.Context) -> None:
self.refresh(context)
class DimensionLinePositionWidget(types.GizmoGroup):
"""Drag handle for the LinePosition of a parametric dimension annotation.
Shows two opposing cones at the midpoint of the dimension curve, oriented
along the horizontal offset axis (cross(world_Z, dim_direction)). Dragging
either cone updates BBIM_Dimension.LinePosition and regenerates the curve in
real time. The forward cone points in +offset_dir; the reverse cone in
-offset_dir both respond to mouse movement along the shared axis so the
user can drag in either direction from either handle.
"""
bl_idname = "BIM_GGT_dimension_line_position"
bl_label = "Dimension Line Position"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT", "SHOW_MODAL_ALL"}
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"))
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not tool.Ifc.get():
return False
obj = context.active_object
if not obj or obj.type != "CURVE":
return False
element = tool.Ifc.get_entity(obj)
if not element or not element.is_a("IfcAnnotation"):
return False
import ifcopenshell.util.element as _ue
if _ue.get_predefined_type(element) not in cls._DIM_TYPES:
return False
pset = _ue.get_pset(element, "BBIM_Dimension")
return bool(pset and pset.get("Anchors"))
# ------------------------------------------------------------------
# Helpers
@staticmethod
def _cam_dir() -> "Vector | None":
"""Scene camera forward direction, or None."""
cam = bpy.context.scene.camera
if not cam:
return None
return (cam.matrix_world.to_3x3() @ Vector((0.0, 0.0, -1.0))).normalized()
@classmethod
def _offset_dir(cls, obj: bpy.types.Object) -> "Vector | None":
"""World-space direction perpendicular to the dimension line and in the view plane.
Plan view (camera mostly vertical): cross(world_Z, dim_dir) preserves
existing stored LinePosition values.
Section/elevation (camera mostly horizontal): cross(cam_forward, dim_dir)
keeps the offset axis inside the view plane so the gizmo moves the line
visually sideways (up/down in section) rather than into/out of the screen.
"""
if not obj.data or not hasattr(obj.data, "splines") or not obj.data.splines:
return None
spline = obj.data.splines[0]
if len(spline.points) < 2:
return None
a = obj.matrix_world @ spline.points[0].co.to_3d()
b = obj.matrix_world @ spline.points[-1].co.to_3d()
dim = b - a
if dim.length < 1e-10:
return None
dim.normalize()
cam_view = cls._cam_dir()
cam_is_plan = (cam_view is None) or abs(cam_view.z) > 0.7
ref = Vector((0.0, 0.0, 1.0)) if cam_is_plan else cam_view
od = ref.cross(dim)
if od.length < 1e-6:
od = Vector((1.0, 0.0, 0.0)).cross(dim)
if od.length < 1e-6:
return None
return od.normalized()
@staticmethod
def _midpoint(obj: bpy.types.Object) -> "Vector":
spline = obj.data.splines[0]
pts = [obj.matrix_world @ p.co.to_3d() for p in spline.points]
return sum(pts, Vector()) / len(pts)
@staticmethod
def _basis(origin: "Vector", x_axis: "Vector") -> "Matrix":
"""4×4 matrix with translation=origin, local-X=x_axis."""
ref = Vector((0.0, 0.0, 1.0)) if abs(x_axis.dot(Vector((0.0, 0.0, 1.0)))) < 0.9 else Vector((1.0, 0.0, 0.0))
y_ax = x_axis.cross(ref).normalized()
z_ax = x_axis.cross(y_ax)
return Matrix([
[x_axis.x, y_ax.x, z_ax.x, origin.x],
[x_axis.y, y_ax.y, z_ax.y, origin.y],
[x_axis.z, y_ax.z, z_ax.z, origin.z],
[0.0, 0.0, 0.0, 1.0],
])
# ------------------------------------------------------------------
# Value callbacks
def _get_pos(self) -> float:
obj = bpy.context.active_object
if not obj:
return 0.0
element = tool.Ifc.get_entity(obj)
if not element:
return 0.0
import ifcopenshell.util.element as _ue
pset = _ue.get_pset(element, "BBIM_Dimension")
if not pset:
return 0.0
stored = pset.get("LinePosition")
if stored is not None:
return float(stored)
# Natural position: projection of midpoint onto offset axis
od = self._offset_dir(obj)
if od is None:
return 0.0
return self._midpoint(obj).dot(od)
def _set_pos(self, value: float) -> None:
bpy.ops.ed.undo_push(message="Set Line Position")
import json
import numpy as np
import ifcopenshell.util.element as _ue
import ifcopenshell.api.pset as _pset_api
import ifcopenshell.api.drawing as drawing_api
from bonsai.bim.module.drawing.operator import _update_blender_curve
obj = bpy.context.active_object
if not obj:
return
file = tool.Ifc.get()
if not file:
return
element = tool.Ifc.get_entity(obj)
if not element:
return
pset_data = _ue.get_pset(element, "BBIM_Dimension")
if not pset_data:
return
pset_entity = file.by_id(pset_data["id"])
_pset_api.edit_pset(file, pset=pset_entity, properties={"LinePosition": value})
anchors = json.loads(pset_data.get("Anchors") or "[]")
placement_override: dict = {}
for a in anchors:
guid = a.get("guid")
if not guid:
continue
try:
elem = file.by_guid(guid)
elem_obj = tool.Ifc.get_object(elem)
if elem_obj:
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
except Exception:
pass
cam_view = self._cam_dir()
cam_dir_tuple = tuple(cam_view) if cam_view is not None else None
resolved_pts = drawing_api.regenerate_dimension(
file, element, placement_override=placement_override, camera_dir=cam_dir_tuple
)
if resolved_pts:
_update_blender_curve(element, resolved_pts)
tool.Blender.update_viewport()
# ------------------------------------------------------------------
# GizmoGroup interface
def _make_cone(self, color: tuple, highlight: tuple) -> "bpy.types.Gizmo":
gz = self.gizmos.new("BIM_GT_gizmo_cone")
gz.color = color
gz.alpha = 0.8
gz.color_highlight = highlight
gz.alpha_highlight = 1.0
gz.scale_basis = 0.15
gz.use_draw_modal = True
gz.prop_name = "Line Position"
gz.move_get_cb = self._get_pos
gz.move_set_cb = self._set_pos
gz.gizmo_group = self
gz.delta_scale = 1.0
return gz
def setup(self, context: bpy.types.Context) -> None:
color = (0.9, 0.6, 0.1)
highlight = (1.0, 0.9, 0.2)
self.gz_fwd = self._make_cone(color, highlight)
self.gz_rev = self._make_cone(color, highlight)
def refresh(self, context: bpy.types.Context) -> None:
obj = context.active_object
if not obj:
self.gz_fwd.hide = self.gz_rev.hide = True
return
od = self._offset_dir(obj)
if od is None:
self.gz_fwd.hide = self.gz_rev.hide = True
return
mid = self._midpoint(obj)
# Lift each cone off the dimension line so the arrow base doesn't
# overlap anchor dots. 0.3 m gives clear separation at typical zoom.
_GAP = 0.15
fwd_origin = mid + _GAP * od
rev_origin = mid - _GAP * od
self.gz_fwd.matrix_basis = self._basis(fwd_origin, od)
self.gz_fwd.axis = od.copy()
self.gz_fwd.hide = False
# Reverse cone: visually points in -od; same drag axis so both cones
# respond identically — drag toward either tip to move the line.
self.gz_rev.matrix_basis = self._basis(rev_origin, -od)
self.gz_rev.axis = od.copy()
self.gz_rev.hide = False
@staticmethod
def get_scale_value(system: str, length_unit: str) -> float:
scale_value = 1
@@ -3022,76 +2645,6 @@ class DimensionLinePositionWidget(types.GizmoGroup):
return scale_value
class DimensionDriveLabelWidget(types.GizmoGroup):
"""Pen-icon gizmos at each segment midpoint of the active parametric dimension.
Clicking a pen invokes ``bim.drive_dimension_length`` for that segment,
opening a dialog pre-filled with the current length.
"""
bl_idname = "BIM_GGT_dimension_drive_label"
bl_label = "Dimension Drive Label"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT", "SHOW_MODAL_ALL"}
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"))
_MAX_SEGMENTS = 15
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not tool.Ifc.get():
return False
obj = context.active_object
if not obj or obj.type != "CURVE":
return False
element = tool.Ifc.get_entity(obj)
if not element or not element.is_a("IfcAnnotation"):
return False
import ifcopenshell.util.element as _ue
if _ue.get_predefined_type(element) not in cls._DIM_TYPES:
return False
pset = _ue.get_pset(element, "BBIM_Dimension")
return bool(pset and pset.get("Anchors"))
def setup(self, context: bpy.types.Context) -> None:
self._labels: list = []
for _ in range(self._MAX_SEGMENTS):
gz = self.gizmos.new("BIM_GT_drive_dim_label")
gz.color = (0.9, 0.75, 0.1)
gz.color_highlight = (1.0, 0.95, 0.3)
gz.alpha = 0.85
gz.alpha_highlight = 1.0
gz.scale_basis = 0.18
gz.use_draw_modal = True
gz.hide = True
self._labels.append(gz)
def refresh(self, context: bpy.types.Context) -> None:
obj = context.active_object
if not obj or not obj.data or not getattr(obj.data, "splines", None) or not obj.data.splines:
for gz in self._labels:
gz.hide = True
return
spline = obj.data.splines[0]
pts = [obj.matrix_world @ p.co.to_3d() for p in spline.points]
n_segs = min(len(pts) - 1, self._MAX_SEGMENTS)
for i in range(n_segs):
gz = self._labels[i]
mid = (pts[i] + pts[i + 1]) * 0.5
gz.matrix_basis = Matrix.Translation(mid)
gz.segment_index = i
gz.hide = False
for i in range(n_segs, self._MAX_SEGMENTS):
self._labels[i].hide = True
def draw_prepare(self, context: bpy.types.Context) -> None:
self.refresh(context)
# ============================================================================
# Core Gizmo Classes
# ============================================================================
@@ -4058,24 +3611,6 @@ class GizmoPen(StaticTrisGizmoMixin, bpy.types.Gizmo):
)
class GizmoDriveDimLabel(bpy.types.Gizmo):
"""Visual-only pen icon at a parametric dimension segment midpoint.
No draw_select/invoke click handling is done by ClickNearestDimensionAnchor,
which dispatches bim.drive_dimension_length on a plain LMB at a midpoint.
"""
bl_idname = "BIM_GT_drive_dim_label"
__slots__ = ("segment_index", "custom_shape")
def setup(self):
self.segment_index = 0
self.custom_shape = self.new_custom_shape("TRIS", GizmoPen.tris)
def draw(self, context):
self.draw_custom_shape(self.custom_shape)
class GizmoValidate(StaticTrisGizmoMixin, bpy.types.Gizmo):
"""Validate/checkmark icon gizmo for confirming edits."""
@@ -16,148 +16,15 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import json
import bpy
import numpy as np
from bpy.app.handlers import persistent
import bonsai.bim.module.drawing.decoration as decoration
import bonsai.tool as tool
# ---------------------------------------------------------------------------
# Parametric dimension auto-regeneration state
# ---------------------------------------------------------------------------
# Maps element GUID → list of annotation STEP IDs that reference it.
_dim_guid_index: dict = {}
# Persistent tessellation cache for the depsgraph handler (element id → shape).
_dim_shape_cache: dict = {}
# Set True whenever BBIM_Dimension anchors change or a new file loads.
_dim_index_dirty: bool = True
# Re-entry guard so curve updates don't trigger a second handler call.
_dim_handler_running: bool = False
def invalidate_dim_index() -> None:
"""Mark the GUID index as stale so it is rebuilt on the next handler call."""
global _dim_index_dirty, _dim_shape_cache
_dim_index_dirty = True
_dim_shape_cache.clear()
def _rebuild_dim_guid_index(file) -> None:
global _dim_guid_index, _dim_index_dirty
import ifcopenshell.util.element
_dim_guid_index = {}
for annotation in file.by_type("IfcAnnotation"):
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
if not pset_data or not pset_data.get("Anchors"):
continue
try:
anchors = json.loads(pset_data["Anchors"])
except Exception:
continue
ann_id = annotation.id()
for anchor in anchors:
guid = anchor.get("guid")
if not guid:
continue
ids = _dim_guid_index.setdefault(guid, [])
if ann_id not in ids:
ids.append(ann_id)
_dim_index_dirty = False
def regenerate_dims_for_layer(file, layer) -> None:
"""Regenerate all parametric dimensions anchored to elements that use *layer*."""
global _dim_shape_cache, _dim_index_dirty, _dim_guid_index
if _dim_index_dirty:
_rebuild_dim_guid_index(file)
affected_guids: set = set()
for layer_set in file.get_inverse(layer):
if not layer_set.is_a("IfcMaterialLayerSet"):
continue
for inv in file.get_inverse(layer_set):
if inv.is_a("IfcRelAssociatesMaterial"):
rels = [inv]
elif inv.is_a("IfcMaterialLayerSetUsage"):
rels = [r for r in file.get_inverse(inv) if r.is_a("IfcRelAssociatesMaterial")]
else:
continue
for rel in rels:
for element in rel.RelatedObjects:
if hasattr(element, "GlobalId"):
affected_guids.add(element.GlobalId)
_dim_shape_cache.pop(element.id(), None)
if not affected_guids:
return
annotation_ids: set = set()
for guid in affected_guids:
for ann_id in _dim_guid_index.get(guid, []):
annotation_ids.add(ann_id)
if not annotation_ids:
return
import ifcopenshell.util.element
import ifcopenshell.api.drawing as drawing_api
import ifcopenshell.geom
from bonsai.bim.module.drawing.operator import _update_blender_curve
geom_settings = ifcopenshell.geom.settings()
geom_settings.set("APPLY_DEFAULT_MATERIALS", False)
cam = bpy.context.scene.camera
cam_dir_tuple = None
if cam:
from mathutils import Vector as _Vec
cam_dir_tuple = tuple((cam.matrix_world.to_3x3() @ _Vec((0, 0, -1))).normalized())
for ann_id in annotation_ids:
try:
annotation = file.by_id(ann_id)
except Exception:
continue
pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
if not pset:
continue
placement_override: dict = {}
try:
anchors_raw = json.loads(pset.get("Anchors") or "[]")
for anchor in anchors_raw:
guid = anchor.get("guid")
if not guid:
continue
try:
elem = file.by_guid(guid)
elem_obj = tool.Ifc.get_object(elem)
if elem_obj:
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
except Exception:
pass
except Exception:
pass
resolved_pts = drawing_api.regenerate_dimension(
file,
annotation,
settings=geom_settings,
shape_cache=_dim_shape_cache,
placement_override=placement_override,
camera_dir=cam_dir_tuple,
)
if resolved_pts:
_update_blender_curve(annotation, resolved_pts)
@persistent
def load_post(*args):
invalidate_dim_index()
props = tool.Drawing.get_document_props()
if props.should_draw_decorations:
decoration.DecorationsHandler.install(bpy.context)
@@ -194,193 +61,3 @@ def set_active_camera_resolution(scene: bpy.types.Scene) -> None:
raster_x, raster_y = props.update_camera_resolution()
scene_render.resolution_x = raster_x
scene_render.resolution_y = raster_y
def _sync_dimension_anchors_to_curve(file, annotation, obj) -> bool:
"""Sync BBIM_Dimension.Anchors length to match the curve's spline point count.
Called when the user adds or removes vertices from a dimension annotation in
Edit Mode. New vertices get a free WORLD-type anchor at their current world
position; removed tail vertices simply lose their anchor entries.
Returns True if the pset was changed.
"""
import ifcopenshell.util.element
import ifcopenshell.api.pset
if not obj.data or not getattr(obj.data, "splines", None) or not obj.data.splines:
return False
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
if not pset_data or not pset_data.get("Anchors"):
return False
try:
anchors: list = json.loads(pset_data["Anchors"])
except Exception:
return False
spline = obj.data.splines[0]
spline_world = [obj.matrix_world @ p.co.to_3d() for p in spline.points]
n_pts = len(spline_world)
n_anchors = len(anchors)
if n_pts == n_anchors:
return False
# Match each spline point to the nearest unused anchor by proximity.
# This handles insertions (subdivide) and deletions correctly regardless
# of where in the polyline the edit happened.
_MATCH_THRESH_SQ = 1e-4 # 1 cm² — distinguishes existing pts from new midpoints
used: set = set()
new_anchors: list = []
for pt in spline_world:
best_idx, best_sq = None, float("inf")
for i, anc in enumerate(anchors):
if i in used:
continue
stored = anc.get("pt")
if not stored:
continue
dx, dy, dz = stored[0] - pt.x, stored[1] - pt.y, stored[2] - pt.z
sq = dx * dx + dy * dy + dz * dz
if sq < best_sq:
best_sq, best_idx = sq, i
if best_idx is not None and best_sq < _MATCH_THRESH_SQ:
new_anchors.append(anchors[best_idx])
used.add(best_idx)
else:
new_anchors.append({
"guid": None,
"type": "WORLD",
"addr": {},
"hint": None,
"pt": [pt.x, pt.y, pt.z],
})
pset_entity = file.by_id(pset_data["id"])
ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties={"Anchors": json.dumps(new_anchors)})
invalidate_dim_index()
return True
@persistent
def depsgraph_update_post_handler(scene, depsgraph):
"""Auto-regenerate parametric dimensions when referenced elements are moved."""
global _dim_handler_running, _dim_index_dirty, _dim_guid_index, _dim_shape_cache
if _dim_handler_running:
return
file = tool.Ifc.get()
if not file:
return
if _dim_index_dirty:
_rebuild_dim_guid_index(file)
import ifcopenshell.util.element
moved_guids: set = set()
edited_annotation_ids: set = set()
for update in depsgraph.updates:
obj = update.id
if not isinstance(obj, bpy.types.Object):
continue
if not (update.is_updated_transform or update.is_updated_geometry):
continue
element = tool.Ifc.get_entity(obj)
if element is None or not hasattr(element, "GlobalId"):
continue
if update.is_updated_geometry and obj.type == "CURVE" and element.is_a("IfcAnnotation"):
import ifcopenshell.util.element as _ue
ptype = _ue.get_predefined_type(element)
if ptype in ("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"):
changed = _sync_dimension_anchors_to_curve(file, element, obj)
if changed:
edited_annotation_ids.add(element.id())
continue
moved_guids.add(element.GlobalId)
if update.is_updated_geometry:
_dim_shape_cache.pop(element.id(), None)
annotation_ids: set = set(edited_annotation_ids)
for guid in moved_guids:
for ann_id in _dim_guid_index.get(guid, []):
annotation_ids.add(ann_id)
if not annotation_ids:
return
import ifcopenshell.api.drawing as drawing_api
import ifcopenshell.geom
from bonsai.bim.module.drawing.operator import _update_blender_curve, _update_elevation_marker_z
geom_settings = ifcopenshell.geom.settings()
geom_settings.set("APPLY_DEFAULT_MATERIALS", False)
cam = bpy.context.scene.camera
cam_dir_tuple = None
if cam:
from mathutils import Vector as _Vec
cam_dir_tuple = tuple((cam.matrix_world.to_3x3() @ _Vec((0, 0, -1))).normalized())
_dim_handler_running = True
try:
for ann_id in annotation_ids:
try:
annotation = file.by_id(ann_id)
except Exception:
continue
pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
if not pset:
continue
placement_override: dict = {}
try:
anchors_raw = json.loads(pset.get("Anchors") or "[]")
for anchor in anchors_raw:
guid = anchor.get("guid")
if not guid:
continue
try:
elem = file.by_guid(guid)
elem_id = elem.id()
if elem_id in placement_override:
continue
elem_obj = tool.Ifc.get_object(elem)
if elem_obj:
placement_override[elem_id] = np.array(elem_obj.matrix_world)
except Exception:
pass
except Exception:
pass
ptype = ifcopenshell.util.element.get_predefined_type(annotation)
if ptype in ("SECTION_LEVEL", "PLAN_LEVEL"):
_update_elevation_marker_z(
file, annotation,
settings=geom_settings,
shape_cache=_dim_shape_cache,
placement_override=placement_override,
)
else:
resolved_pts = drawing_api.regenerate_dimension(
file,
annotation,
settings=geom_settings,
shape_cache=_dim_shape_cache,
placement_override=placement_override,
camera_dir=cam_dir_tuple,
)
if resolved_pts:
_update_blender_curve(annotation, resolved_pts)
finally:
_dim_handler_running = False
@@ -170,7 +170,6 @@ def format_distance(
precision=None,
decimal_places=None,
suppress_zero_inches=False,
suppress_zero_feet=False,
in_unit_length=False,
custom_unit=None,
):
@@ -320,10 +319,10 @@ def format_distance(
tx_dist = ""
if feet:
tx_dist += str(feet) + "'"
if not feet and not add_inches and not suppress_zero_feet:
if not feet and not add_inches:
tx_dist += str(feet) + "'"
if not feet and add_inches and unit_length != "INCHES" and not suppress_zero_feet:
if not feet and add_inches and unit_length != "INCHES":
if value < 0:
tx_dist += "-0' - "
else:
File diff suppressed because it is too large Load Diff
@@ -95,17 +95,6 @@ def update_diagram_scale(self: "BIMCameraProperties", context: bpy.types.Context
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties=diagram_scale)
self.update_camera_resolution()
group = tool.Drawing.get_drawing_group(element)
print(f"[SECTION] update_diagram_scale: camera={camera.name}, group={group}")
if group:
for annotation in tool.Drawing.get_group_elements(group) or []:
print(f"[SECTION] checking group member: {annotation}")
if annotation.is_a("IfcAnnotation") and ifcopenshell.util.element.get_predefined_type(annotation) == "SECTION":
ann_obj = tool.Ifc.get_object(annotation)
print(f"[SECTION] found SECTION annotation, ann_obj={ann_obj}")
if ann_obj:
tool.Drawing.update_section_endpoints(ann_obj, camera)
def update_is_nts(self: "BIMCameraProperties", context: bpy.types.Context) -> None:
if not self.update_props:
@@ -420,12 +409,6 @@ class DocProperties(PropertyGroup):
options=set(),
)
is_editing_drawings: BoolProperty(name="Is Editing Drawings", default=False)
show_drawings_on_sheets_only: BoolProperty(
name="Show Only Drawings on Sheets",
description="Only show drawings that are placed on a sheet",
default=False,
options=set(),
)
is_editing_schedules: BoolProperty(name="Is Editing Schedules", default=False)
is_editing_references: BoolProperty(name="Is Editing References", default=False)
target_view: EnumProperty(
@@ -456,7 +439,6 @@ class DocProperties(PropertyGroup):
should_use_annotation_cache: bool
should_draw_linked_projects: bool
is_editing_drawings: bool
show_drawings_on_sheets_only: bool
is_editing_schedules: bool
is_editing_references: bool
target_view: Literal["PLAN_VIEW", "ELEVATION_VIEW", "SECTION_VIEW", "REFLECTED_PLAN_VIEW", "MODEL_VIEW"]
@@ -1049,182 +1031,6 @@ def update_sheet_data(self, context):
SheetsData.is_loaded = False
def _update_force_perpendicular(self, context):
"""Apply ForcePerpendicularToFace to all selected dimension annotations and regenerate them."""
import json
import numpy as np
import ifcopenshell.util.element
import ifcopenshell.api.pset
import ifcopenshell.api.drawing as drawing_api
import bonsai.tool as tool
file = tool.Ifc.get()
if not file:
return
new_value = self.force_perpendicular_to_face
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"))
targets = []
for obj in context.selected_objects:
element = tool.Ifc.get_entity(obj)
if not element or not element.is_a("IfcAnnotation"):
continue
if ifcopenshell.util.element.get_predefined_type(element) not in _DIM_TYPES:
continue
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension")
if not pset_data:
continue
targets.append((obj, element, pset_data))
if not targets:
return
from bonsai.bim.module.drawing.operator import _update_blender_curve
for obj, element, pset_data in targets:
pset_entity = file.by_id(pset_data["id"])
ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties={"ForcePerpendicularToFace": new_value})
anchors = json.loads(pset_data.get("Anchors") or "[]")
placement_override = {}
for a in anchors:
guid = a.get("guid")
if not guid:
continue
try:
elem = file.by_guid(guid)
elem_obj = tool.Ifc.get_object(elem)
if elem_obj:
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
except Exception:
pass
resolved_pts = drawing_api.regenerate_dimension(file, element, placement_override=placement_override)
if resolved_pts:
_update_blender_curve(element, resolved_pts)
def _get_line_position(self) -> float:
"""Return LinePosition from the active annotation's BBIM_Dimension pset.
Falls back to the natural anchor projection when LinePosition has not been
explicitly set, so the field always shows a meaningful value.
"""
import math
import json
try:
import bpy as _bpy
import ifcopenshell.util.element as _ue
import bonsai.tool as _tool
obj = getattr(_bpy.context, "active_object", None)
if obj:
element = _tool.Ifc.get_entity(obj)
if element and element.is_a("IfcAnnotation"):
pset = _ue.get_pset(element, "BBIM_Dimension")
if pset:
stored = pset.get("LinePosition")
if stored is not None:
return float(stored)
raw = pset.get("Anchors")
if raw:
anchors = json.loads(raw)
if len(anchors) >= 2 and anchors[0].get("pt") and anchors[1].get("pt"):
a, b = anchors[0]["pt"], anchors[1]["pt"]
dx, dy, dz = b[0] - a[0], b[1] - a[1], b[2] - a[2]
m = math.sqrt(dx * dx + dy * dy + dz * dz)
if m > 1e-10:
ddx, ddy, ddz = dx / m, dy / m, dz / m
cam = _bpy.context.scene.camera
cam_is_plan = True
cvx, cvy, cvz = 0.0, 0.0, 1.0
if cam:
from mathutils import Vector as _Vec
cv = (cam.matrix_world.to_3x3() @ _Vec((0, 0, -1))).normalized()
cvx, cvy, cvz = cv.x, cv.y, cv.z
cam_is_plan = abs(cvz) > 0.7
if cam_is_plan:
# cross(world_Z, dim_dir)
ox, oy, oz = -ddy, ddx, 0.0
else:
# cross(cam_dir, dim_dir)
ox = cvy * ddz - cvz * ddy
oy = cvz * ddx - cvx * ddz
oz = cvx * ddy - cvy * ddx
om = math.sqrt(ox * ox + oy * oy + oz * oz)
if om > 1e-6:
od = (ox / om, oy / om, oz / om)
pt = anchors[0]["pt"]
return float(pt[0] * od[0] + pt[1] * od[1] + pt[2] * od[2])
except Exception:
pass
return 0.0
def _set_line_position(self, value: float) -> None:
"""Write LinePosition to all selected dimension annotations and regenerate."""
import json
import numpy as np
import ifcopenshell.util.element
import ifcopenshell.api.pset
import ifcopenshell.api.drawing as drawing_api
import bonsai.tool as tool
file = tool.Ifc.get()
if not file:
return
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"))
targets = []
import bpy as _bpy
for obj in getattr(_bpy.context, "selected_objects", []):
element = tool.Ifc.get_entity(obj)
if not element or not element.is_a("IfcAnnotation"):
continue
if ifcopenshell.util.element.get_predefined_type(element) not in _DIM_TYPES:
continue
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension")
if not pset_data:
continue
targets.append((obj, element, pset_data))
if not targets:
return
from bonsai.bim.module.drawing.operator import _update_blender_curve
cam = _bpy.context.scene.camera
cam_dir_tuple = None
if cam:
from mathutils import Vector as _Vec
cam_dir_tuple = tuple((cam.matrix_world.to_3x3() @ _Vec((0, 0, -1))).normalized())
for obj, element, pset_data in targets:
pset_entity = file.by_id(pset_data["id"])
ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties={"LinePosition": value})
anchors = json.loads(pset_data.get("Anchors") or "[]")
placement_override = {}
for a in anchors:
guid = a.get("guid")
if not guid:
continue
try:
elem = file.by_guid(guid)
elem_obj = tool.Ifc.get_object(elem)
if elem_obj:
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
except Exception:
pass
resolved_pts = drawing_api.regenerate_dimension(
file, element, placement_override=placement_override, camera_dir=cam_dir_tuple
)
if resolved_pts:
_update_blender_curve(element, resolved_pts)
class BIMAnnotationProperties(PropertyGroup):
object_type: bpy.props.EnumProperty(
name="Annotation Object Type", items=annotation_classes, default="TEXT", update=update_annotation_object_type
@@ -1238,19 +1044,6 @@ class BIMAnnotationProperties(PropertyGroup):
)
is_adding_type: bpy.props.BoolProperty(default=False)
type_name: bpy.props.StringProperty(name="Name", default="TYPEX")
force_perpendicular_to_face: bpy.props.BoolProperty(
name="Force ⊥ to Face",
description="Constrain dimension vertices to the face normal of the first anchor. When dimensions are selected, toggling this updates them all.",
default=False,
update=_update_force_perpendicular,
)
line_position: bpy.props.FloatProperty(
name="Line Position",
description="Absolute world position of the dimension line along the horizontal axis perpendicular to the dimension. The line is held at this fixed global coordinate even when the measured geometry moves. Updates all selected dimensions.",
unit="LENGTH",
get=_get_line_position,
set=_set_line_position,
)
tag_rotation_mode: bpy.props.EnumProperty(
name="Tag Rotation Mode",
description="How to orient the tag relative to the tagged object",
@@ -872,11 +872,7 @@ class SvgWriter:
v1 = self.project_point_onto_camera(obj.matrix_world @ Vector((0, 0, 0)))
v2 = self.project_point_onto_camera(obj.matrix_world @ Vector((0, 0, -1)))
delta = (v2 - v1).xy
if delta.length <= 1e-6:
v2 = self.project_point_onto_camera(obj.matrix_world @ Vector((1, 0, 0)))
delta = (v2 - v1).xy
angle = -math.degrees(delta.angle_signed(Vector((0, 1)))) if delta.length > 1e-6 else 90.0
angle = -math.degrees((v2 - v1).xy.angle_signed(Vector((0, 1))))
transform = "rotate({}, {}, {})".format(angle, *symbol_position_svg.xy)
@@ -899,8 +895,6 @@ class SvgWriter:
reference_id = "-"
sheet_id = "-"
drawing = tool.Drawing.get_annotation_element(element)
if not drawing:
return ("-", "-")
reference = tool.Drawing.get_drawing_reference(drawing)
if reference:
for sheet_reference in tool.Ifc.get().by_type("IfcDocumentReference"):
@@ -1373,18 +1367,14 @@ class SvgWriter:
def get_text():
radius = (points[-1].co - points[-2].co).length
units_to_format = dimension_data["custom_units"] if dimension_data["custom_units"] else [None]
parts = [
helper.format_distance(
radius,
precision=self.precision,
decimal_places=self.decimal_places,
suppress_zero_feet=dimension_data["suppress_zero_feet"],
custom_unit=unit,
)
for unit in units_to_format
]
return "R" + dimension_data["separator"].join(str(p) for p in parts)
radius = helper.format_distance(
radius,
precision=self.precision,
decimal_places=self.decimal_places,
custom_unit=dimension_data["custom_unit"],
)
text = f"R{radius}"
return text
self.draw_dimension_text(
get_text, tag, dimension_data, text_position=text_position, class_str="RADIUS", box_alignment="center"
@@ -1511,12 +1501,10 @@ class SvgWriter:
text_format=lambda x: "D" + x,
show_description_only=dimension_data["show_description_only"],
suppress_zero_inches=dimension_data["suppress_zero_inches"],
suppress_zero_feet=dimension_data["suppress_zero_feet"],
text_prefix=dimension_data["text_prefix"],
text_suffix=dimension_data["text_suffix"],
fill_bg=dimension_data["fill_bg"],
custom_units=dimension_data["custom_units"],
separator=dimension_data["separator"],
custom_unit=dimension_data["custom_unit"],
)
def draw_dimension_annotations(self, obj: bpy.types.Object) -> None:
@@ -1527,15 +1515,11 @@ class SvgWriter:
dimension_data = DecoratorData.get_dimension_data(obj)
assert isinstance(obj.data, bpy.types.Curve)
is_ordinate = dimension_data["is_ordinate"]
for spline in obj.data.splines:
points = self.get_spline_points(spline)
ordinate_total = 0.0
for i in range(len(points) - 1):
v0_global = matrix_world @ points[i].co.xyz
v1_global = matrix_world @ points[i + 1].co.xyz
if is_ordinate:
ordinate_total += (v1_global - v0_global).length
self.draw_dimension_annotation(
v0_global,
v1_global,
@@ -1543,13 +1527,10 @@ class SvgWriter:
dimension_text=dimension_text,
show_description_only=dimension_data["show_description_only"],
suppress_zero_inches=dimension_data["suppress_zero_inches"],
suppress_zero_feet=dimension_data["suppress_zero_feet"],
text_prefix=dimension_data["text_prefix"],
text_suffix=dimension_data["text_suffix"],
fill_bg=dimension_data["fill_bg"],
custom_units=dimension_data["custom_units"],
separator=dimension_data["separator"],
distance_override=ordinate_total if is_ordinate else None,
custom_unit=dimension_data["custom_unit"],
)
def draw_measureit_arch_dimension_annotations(self) -> None:
@@ -1573,13 +1554,10 @@ class SvgWriter:
text_format=lambda x: x,
show_description_only=False,
suppress_zero_inches=False,
suppress_zero_feet=False,
text_prefix="",
text_suffix="",
fill_bg=False,
custom_units=None,
separator=" / ",
distance_override=None,
custom_unit=None,
) -> None:
offset = Vector([self.raw_width, self.raw_height]) / 2
v0 = self.project_point_onto_camera(v0_global)
@@ -1592,10 +1570,7 @@ class SvgWriter:
sheet_dimension = (end - start).length
# if annotation can't fit offset text to the right of marker
if distance_override is not None:
text_position = end
else:
text_position = mid if sheet_dimension > 5 else (end + (3 * vector.normalized()))
text_position = mid if sheet_dimension > 5 else (end + (3 * vector.normalized()))
angle = math.degrees(vector.angle_signed(Vector((1, 0))))
line = self.svg.line(start=start, end=end, class_=" ".join(classes))
@@ -1610,20 +1585,15 @@ class SvgWriter:
}
if not show_description_only:
dimension = distance_override if distance_override is not None else (v1_global - v0_global).length
units_to_format = custom_units if custom_units else [None]
parts = [
helper.format_distance(
dimension,
precision=self.precision,
decimal_places=self.decimal_places,
suppress_zero_inches=suppress_zero_inches,
suppress_zero_feet=suppress_zero_feet,
custom_unit=unit,
)
for unit in units_to_format
]
text = text_prefix + separator.join(str(p) for p in parts) + text_suffix
dimension = (v1_global - v0_global).length
dimension = helper.format_distance(
dimension,
precision=self.precision,
decimal_places=self.decimal_places,
suppress_zero_inches=suppress_zero_inches,
custom_unit=custom_unit,
)
text = text_prefix + str(dimension) + text_suffix
else:
if not dimension_text:
return
@@ -1631,8 +1601,8 @@ class SvgWriter:
text_tags += self.create_text_tag(
text,
text_position + perpendicular + (Vector((0, 1.5)) if distance_override is not None else Vector((0, 0))),
box_alignment="bottom-right" if distance_override is not None else "bottom-middle",
text_position + perpendicular,
box_alignment="bottom-middle",
multiline_to_bottom=False,
**text_tag_kwargs,
)
@@ -1640,8 +1610,8 @@ class SvgWriter:
if not show_description_only and dimension_text:
text_tags += self.create_text_tag(
dimension_text,
text_position - perpendicular + (Vector((0, 1.5)) if distance_override is not None else Vector((0, 0))),
box_alignment="top-right" if distance_override is not None else "top-middle",
text_position - perpendicular,
box_alignment="top-middle",
multiline_to_bottom=True,
**text_tag_kwargs,
)
+2 -67
View File
@@ -341,7 +341,6 @@ class BIM_PT_drawings(Panel):
self.layout.template_list(
"BIM_UL_drawinglist", "", self.props, "drawings", self.props, "active_drawing_index"
)
self.layout.prop(self.props, "show_drawings_on_sheets_only")
class BIM_PT_schedules(Panel):
@@ -555,17 +554,6 @@ class BIM_PT_product_assignments(Panel):
assert self.layout
assert (obj := context.active_object)
element = tool.Ifc.get_entity(obj)
if element and tool.Drawing.is_manual_drawing_reference(element):
row = self.layout.row(align=True)
fallback = "No Reference Assigned" if element.ObjectType == "REFERENCE" else "No Drawing Assigned"
row.label(
text=ProductAssignmentsData.data["relating_product"] or fallback, icon="IMAGE_DATA"
)
row.operator("bim.assign_manual_drawing_reference", icon="GREASEPENCIL", text="")
return
props = tool.Drawing.get_object_assigned_product_props(obj)
if props.is_editing_product:
@@ -583,8 +571,6 @@ class BIM_PT_product_assignments(Panel):
col.enabled = bool(ProductAssignmentsData.data["relating_product"])
def get_category_icon(category_name):
"""Get appropriate icon for each category"""
icons = {
@@ -887,8 +873,8 @@ class BIM_UL_drawinglist(bpy.types.UIList):
layout.label(text="", translate=False)
return
row = layout.row(align=True)
if item.is_drawing:
row = layout.row(align=True)
row.label(text="", icon="BLANK1")
selected_icon = "CHECKBOX_HLT" if item.is_selected else "CHECKBOX_DEHLT"
row.prop(item, "is_selected", text="", icon=selected_icon, emboss=False)
@@ -909,9 +895,6 @@ class BIM_UL_drawinglist(bpy.types.UIList):
item.ifc_definition_id
)
else:
# Give category headers a distinct inset background so they stand out from drawing rows.
box = layout.box()
row = box.row(align=True)
if item.target_view == "PLAN_VIEW":
icon = "UV_FACESEL"
elif item.target_view == "ELEVATION_VIEW":
@@ -932,55 +915,7 @@ class BIM_UL_drawinglist(bpy.types.UIList):
op = row.operator("bim.toggle_target_view", text="", emboss=False, icon="DISCLOSURE_TRI_RIGHT")
op.target_view = item.target_view
op.option = "EXPAND"
group = tool.Drawing.get_visible_drawings_in_category(item.target_view)
all_selected = bool(group) and all(d.is_selected for d in group)
row.operator(
"bim.toggle_drawing_category_selection",
text="",
icon="CHECKBOX_HLT" if all_selected else "CHECKBOX_DEHLT",
emboss=False,
).target_view = item.target_view
row.separator(factor=0.5, type="SPACE")
# Clicking the header name toggles expand/contract, same as the disclosure triangle.
op = row.operator("bim.toggle_target_view", text=item.name, icon=icon, emboss=False)
op.target_view = item.target_view
op.option = "CONTRACT" if item.is_expanded else "EXPAND"
def filter_items(self, context, data: DocProperties, propname: str):
drawings = getattr(data, propname)
helper_funcs = bpy.types.UI_UL_list
flt_flags = []
flt_neworder = []
if self.filter_name:
flt_flags = helper_funcs.filter_items_by_name(
self.filter_name,
self.bitflag_filter_item,
drawings,
"name",
reverse=self.use_filter_sort_reverse,
)
if not flt_flags:
flt_flags = [self.bitflag_filter_item] * len(drawings)
props = tool.Drawing.get_document_props()
if props.show_drawings_on_sheets_only:
ifc_file = tool.Ifc.get()
sheeted_ids = tool.Drawing.get_sheeted_drawing_ids()
# Target view headers are only shown if they contain a sheeted drawing.
sheeted_target_views = {
tool.Drawing.get_drawing_target_view(ifc_file.by_id(drawing_id)) for drawing_id in sheeted_ids
}
for i, item in enumerate(drawings):
if item.is_drawing:
is_visible = item.ifc_definition_id in sheeted_ids
else:
is_visible = item.target_view in sheeted_target_views
if not is_visible:
flt_flags[i] &= ~self.bitflag_filter_item
return flt_flags, flt_neworder
row.prop(item, "name", text="", icon=icon, emboss=False)
class BIM_UL_sheets(bpy.types.UIList):
@@ -114,11 +114,7 @@ class AnnotationTool(WorkSpaceTool):
bl_description = "Gives you Annotation related superpowers"
bl_icon = os.path.join(os.path.dirname(__file__), "ops.authoring.annotation")
bl_widget = None
bl_keymap = (
# Before view3d.select: tool keymaps take priority over the addon keymap
# where ClickNearestDimensionAnchor is also registered.
("bim.click_nearest_dimension_anchor", {"type": "LEFTMOUSE", "value": "PRESS"}, None),
) + tool.Blender.get_default_selection_keypmap() + (
bl_keymap = tool.Blender.get_default_selection_keypmap() + (
("bim.annotation_hotkey", {"type": "A", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_A")]}),
("bim.annotation_hotkey", {"type": "C", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_C")]}),
("bim.annotation_hotkey", {"type": "E", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_E")]}),
@@ -225,68 +221,14 @@ class AnnotationToolUI:
props = tool.Drawing.get_document_props()
row.prop(props, "should_draw_decorations", text="Viewport Annotations")
_DIMENSION_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"))
_ELEVATION_TYPES = frozenset(("SECTION_LEVEL", "PLAN_LEVEL"))
@classmethod
def draw_edit_object_interface(cls, context):
obj = bpy.context.active_object
if tool.Ifc.get_entity(obj) and DecoratorData.get_text_data(obj):
if DecoratorData.get_text_data(bpy.context.active_object):
add_layout_hotkey_operator(cls.layout, "Edit Text", "S_E", "")
if bpy.ops.bim.copy_annotation_to_drawing.poll():
row = cls.layout.row(align=True)
row.operator("bim.copy_annotation_to_drawing", icon="PASTEDOWN", text="Copy To Drawing")
obj = context.active_object
element = tool.Ifc.get_entity(obj) if obj else None
if element and element.is_a("IfcAnnotation"):
ptype = ifcopenshell.util.element.get_predefined_type(element)
if ptype in cls._DIMENSION_TYPES:
cls.layout.separator()
ann_props = tool.Drawing.get_annotation_props()
if ann_props.force_perpendicular_to_face:
row = cls.layout.row(align=True)
row.prop(ann_props, "line_position")
cls.layout.separator()
row = cls.layout.row(align=True)
op = row.operator("bim.regenerate_dimensions", icon="FILE_REFRESH", text="Regenerate")
op.active_only = True
obj = context.active_object
element = tool.Ifc.get_entity(obj) if obj else None
if element and element.is_a("IfcAnnotation"):
ptype = ifcopenshell.util.element.get_predefined_type(element)
if ptype in cls._DIMENSION_TYPES:
cls.layout.separator()
ann_props = tool.Drawing.get_annotation_props()
if ann_props.force_perpendicular_to_face:
row = cls.layout.row(align=True)
row.prop(ann_props, "line_position")
cls.layout.separator()
row = cls.layout.row(align=True)
op = row.operator("bim.regenerate_dimensions", icon="FILE_REFRESH", text="Regenerate")
op.active_only = True
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension")
if pset and pset.get("Anchors"):
row = cls.layout.row(align=True)
row.operator("bim.bake_parametric_dimension", text="Bake to Static", icon="UNLINKED")
else:
row = cls.layout.row(align=True)
row.operator("bim.make_dimension_parametric", text="Make Parametric", icon="LINKED")
elif ptype in cls._ELEVATION_TYPES:
cls.layout.separator()
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension")
if pset and pset.get("Anchors"):
row = cls.layout.row(align=True)
op = row.operator("bim.regenerate_dimensions", icon="FILE_REFRESH", text="Regenerate")
op.active_only = True
row = cls.layout.row(align=True)
row.operator("bim.bake_parametric_dimension", text="Bake to Static", icon="UNLINKED")
else:
row = cls.layout.row(align=True)
row.operator("bim.make_dimension_parametric", text="Make Parametric", icon="LINKED")
@classmethod
def draw_type_selection_interface(cls):
# shared by both sidebar and header
@@ -309,11 +251,6 @@ class AnnotationToolUI:
add_layout_hotkey_operator(cls.layout, "Add", "S_A", "Create a new annotation")
_DIMENSION_TYPES = {"DIMENSION", "RADIUS", "DIAMETER", "ANGLE"}
if object_type in _DIMENSION_TYPES:
row = cls.layout.row(align=True)
row.prop(cls.props, "force_perpendicular_to_face")
if object_type in tool.Drawing.ANNOTATION_TYPES_SUPPORT_SETUP:
row = cls.layout.row(align=True)
row.label(text="", icon="DRIVER_ROTATIONAL_DIFFERENCE")
@@ -396,20 +333,8 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
if created_objects:
bpy.context.view_layer.objects.active = created_objects[-1]
_PARAMETRIC_DIMENSION_TYPES = frozenset(
("DIMENSION", "RADIUS", "DIAMETER", "ANGLE")
)
_ELEVATION_TYPES = frozenset(("SECTION_LEVEL", "PLAN_LEVEL"))
def hotkey_S_A(self):
props = tool.Drawing.get_annotation_props()
if props.object_type in self._PARAMETRIC_DIMENSION_TYPES:
if bpy.ops.bim.draw_parametric_dimension.poll():
bpy.ops.bim.draw_parametric_dimension("INVOKE_DEFAULT")
elif props.object_type in self._ELEVATION_TYPES:
if bpy.ops.bim.add_elevation_annotation.poll():
bpy.ops.bim.add_elevation_annotation("INVOKE_DEFAULT")
elif bpy.ops.bim.add_annotation.poll():
if bpy.ops.bim.add_annotation.poll():
bpy.ops.bim.add_annotation()
def hotkey_S_E(self):
@@ -1325,10 +1325,6 @@ class OverrideDuplicateMove(bpy.types.Operator):
if new_active_obj:
context.view_layer.objects.active = new_active_obj
if any(e.is_a("IfcAnnotation") for e in old_to_new):
import bonsai.bim.module.drawing.handler as _drawing_handler
_drawing_handler.invalidate_dim_index()
return old_to_new
@@ -56,7 +56,6 @@ classes = (
operator.RemoveMaterial,
operator.RemoveMaterialSet,
operator.RemoveProfile,
operator.RenameMaterial,
operator.ReorderMaterialSetItem,
operator.SelectByMaterial,
operator.SelectMaterialInMaterialsUI,
@@ -102,26 +102,6 @@ class EditMaterial(bpy.types.Operator, tool.Ifc.Operator):
core.edit_material(tool.Ifc, tool.Material, material=tool.Ifc.get().by_id(self.material))
class RenameMaterial(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.rename_material"
bl_label = "Rename Material"
bl_description = "Rename an IfcMaterial"
bl_options = {"REGISTER", "UNDO"}
material: bpy.props.IntProperty()
name: bpy.props.StringProperty(name="Name")
def invoke(self, context, event):
material = tool.Ifc.get().by_id(self.material)
self.name = material.Name or ""
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
self.layout.prop(self, "name")
def _execute(self, context):
core.rename_material(tool.Ifc, tool.Material, material=tool.Ifc.get().by_id(self.material), name=self.name)
class DisableEditingMaterial(bpy.types.Operator):
bl_idname = "bim.disable_editing_material"
bl_label = "Disable Editing Material"
@@ -834,8 +814,6 @@ class EditMaterialSetItem(bpy.types.Operator, tool.Ifc.Operator):
)
slab.DumbSlabPlaner().regenerate_from_layer(layer)
wall.DumbWallPlaner().regenerate_from_layer(layer)
from bonsai.bim.module.drawing.handler import regenerate_dims_for_layer
regenerate_dims_for_layer(self.file, layer)
elif material.is_a("IfcMaterialProfileSet"):
profile_def = None
if mprops.profiles:
@@ -796,8 +796,6 @@ class PolylineDecorator(tool.Blender.ViewportDecorator):
rv3d = region.data
polyline_props = tool.Model.get_polyline_props()
if not polyline_props.snap_mouse_point:
return
snap_prop = polyline_props.snap_mouse_point[0]
mouse_point = Vector((snap_prop.x, snap_prop.y, snap_prop.z))
@@ -865,8 +863,6 @@ class PolylineDecorator(tool.Blender.ViewportDecorator):
gpu.state.point_size_set(6)
polyline_props = tool.Model.get_polyline_props()
if not polyline_props.snap_mouse_point:
return
snap_prop = polyline_props.snap_mouse_point[0]
# Point related to the mouse
mouse_point = [Vector((snap_prop.x, snap_prop.y, snap_prop.z))]
+3 -15
View File
@@ -462,26 +462,14 @@ class PolylineOperator:
self.tool_state.axis_method = None
self.tool_state.plane_method = None
self.tool_state.mode = "Mouse"
# Do not call clear_snap_objs() here — create_snap_obj() validates stale
# entries per-object (vertex count + position check), so the BVH cache can
# safely persist across invocations. Clearing it caused an 11-second stall
# on every Shift+A because SnapObj rebuilds a pure-Python BVH tree.
tool.Raycast.clear_snap_objs()
self.visible_objs = tool.Raycast.get_visible_objects(context)
for obj in self.visible_objs:
if bbox_2d := tool.Raycast.get_on_screen_2d_bounding_boxes(context, obj):
self.objs_2d_bbox.append(bbox_2d)
self._init_snapping_points(context, event)
detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox, self.tool_state)
self.snapping_points = tool.Snap.select_snapping_points(context, event, self.tool_state, detected_snaps)
tool.Polyline.calculate_distance_and_angle(context, self.input_ui, self.tool_state)
tool.Blender.update_viewport()
context.window_manager.modal_handler_add(self)
def _init_snapping_points(self, context: bpy.types.Context, event: bpy.types.Event) -> None:
"""Populate self.snapping_points at operator start.
Override in subclasses to skip the full BVH snap detection when a cheap
placeholder is sufficient. The default runs the full detection pass.
"""
detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox, self.tool_state)
self.snapping_points = tool.Snap.select_snapping_points(context, event, self.tool_state, detected_snaps)
+1 -1
View File
@@ -162,7 +162,7 @@ class ProjectLibraryData:
library_file = IfcStore.library_file
if library_file is None or library_file.schema == "IFC2X3":
return results
root = library_file.by_type("IfcProject")[0]
root = tool.Project.get_root_context(library_file)
results.append((str(root.id()), f"{root.is_a()} {root.Name or 'Unnamed'}", root.Description or ""))
for library_id, data in cls.data["project_libraries"].items():
results.append((str(library_id), data["Name"] or "Unnamed", data["Description"] or ""))
@@ -281,7 +281,7 @@ class RefreshLibrary(bpy.types.Operator):
elements = {e for e in elements if not tool.Project.is_element_assigned_to_project_library(e, rels)}
self.props.add_library_project_library("Unassigned", len(elements), 0, False)
root_context = library_file.by_type("IfcProject")[0]
root_context = tool.Project.get_root_context(library_file)
hierarchy = tool.Project.get_project_hierarchy(library_file)
tool.Project.load_project_libraries_to_ui(root_context, hierarchy)
return {"FINISHED"}
@@ -761,22 +761,21 @@ class EditProjectLibrary(bpy.types.Operator):
attributes = bonsai.bim.helper.export_attributes(props.project_library_attributes)
ifcopenshell.api.attribute.edit_attributes(library_file, project_library, attributes)
# Update parent library. Tear down the old IfcRelDeclares/IfcRelNests before
# creating the new one; a library must have exactly one of the two, never both.
# Update parent library.
previous_parent_library = tool.Project.get_parent_library(project_library)
new_parent_library = library_file.by_id(int(props.parent_library))
if previous_parent_library != new_parent_library:
if previous_parent_library is not None:
if previous_parent_library.is_a("IfcProject"):
ifcopenshell.api.project.unassign_declaration(
library_file, [project_library], previous_parent_library
)
else:
ifcopenshell.api.nest.unassign_object(library_file, [project_library])
if new_parent_library.is_a("IfcProject"):
ifcopenshell.api.project.assign_declaration(library_file, [project_library], new_parent_library)
else:
if previous_parent_library is None:
# Edited library was a root in a library-only file; nest it under the new parent.
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
elif previous_parent_library.is_a("IfcProject"):
# Then new one is IfcProjectLibrary.
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
else: # Previous is IfcProjectLibrary.
ifcopenshell.api.nest.unassign_object(library_file, [project_library])
# If new one is IfcProject, then it's already assigned by default.
if new_parent_library.is_a("IfcProjectLibrary"):
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
props.is_editing_project_library = False
bpy.ops.bim.refresh_library()
@@ -810,9 +809,12 @@ class AddProjectLibrary(bpy.types.Operator):
props = tool.Project.get_project_props()
library_file = IfcStore.library_file
assert library_file
root_context = library_file.by_type("IfcProject")[0]
root_context = tool.Project.get_root_context(library_file)
project_library = ifcopenshell.api.root.create_entity(library_file, "IfcProjectLibrary")
ifcopenshell.api.project.assign_declaration(library_file, [project_library], root_context)
if root_context.is_a("IfcProject"):
ifcopenshell.api.project.assign_declaration(library_file, [project_library], root_context)
else:
ifcopenshell.api.nest.assign_object(library_file, [project_library], root_context)
ProjectLibraryData.load() # Update enum.
props.selected_project_library = str(project_library.id())
props.is_editing_project_library = True
@@ -1299,10 +1301,6 @@ class LoadProjectElements(bpy.types.Operator):
tool.Project.set_default_modeling_dimensions()
tool.Root.reload_grid_decorator()
bonsai.bim.handler.refresh_ui_data()
for screen in bpy.data.screens:
for area in screen.areas:
if area.type == "VIEW_3D":
bonsai.bim.handler.viewport_shading_changed_callback(area)
return {"FINISHED"}
def get_decomposition_elements(self) -> set[ifcopenshell.entity_instance]:
@@ -1580,13 +1578,10 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
json_filepath = self.filepath_.with_suffix(".ifc.cache.json")
def should_clear_cache() -> bool:
# Nothing to clear if the cache file was never created (e.g. a
# fresh link). Check this first so os.remove below is never
# called on a non-existent path, regardless of use_cache.
if not blend_filepath.exists():
return False
if not self.use_cache:
return True
if not blend_filepath.exists():
return False
data = json.loads(json_filepath.read_text())
# Empty 'query' - model loaded without custom query.
# Missing 'query' - model was loaded before custom queries were introduced in Bonsai.
@@ -88,44 +88,6 @@ class DisablePsetEditing(bpy.types.Operator, tool.Ifc.Operator):
props.active_pset_type = "-"
def _regenerate_parametric_dimension(file, annotation):
"""Regenerate a single parametric dimension annotation after a pset edit."""
try:
import json
import numpy as np
import ifcopenshell.util.element
import ifcopenshell.api.drawing as drawing_api
import bonsai.tool as _tool
from bonsai.bim.module.drawing.operator import _update_blender_curve
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
if not pset_data or not pset_data.get("Anchors"):
return
anchors = json.loads(pset_data["Anchors"])
placement_override = {}
for a in anchors:
guid = a.get("guid")
if not guid:
continue
try:
elem = file.by_guid(guid)
elem_obj = _tool.Ifc.get_object(elem)
if elem_obj:
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
except Exception:
pass
resolved_pts = drawing_api.regenerate_dimension(
file, annotation, placement_override=placement_override
)
if resolved_pts:
_update_blender_curve(annotation, resolved_pts)
except Exception:
import traceback
traceback.print_exc()
class EditPset(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.edit_pset"
bl_label = "Edit Pset"
@@ -190,12 +152,7 @@ class EditPset(bpy.types.Operator, tool.Ifc.Operator):
)
if tool.Cost.has_schedules():
tool.Cost.update_cost_items(pset=pset)
is_bbim_dimension = props.active_pset_name == "BBIM_Dimension" and element.is_a("IfcAnnotation")
bpy.ops.bim.disable_pset_editing(obj=self.obj, obj_type=self.obj_type)
if is_bbim_dimension:
_regenerate_parametric_dimension(self.file, element)
tool.Blender.update_viewport()
+3 -17
View File
@@ -104,7 +104,7 @@ def get_footing_length(o: bpy.types.Object) -> float:
return get_length(o)
if predefined_type == "FOOTING_BEAM" or predefined_type == "STRIP_FOOTING":
return get_z(o)
elif predefined_type == "PAD_FOOTING" or predefined_type == "PILE_CAP":
elif predefined_type == "PAD_FOOTING":
return max(get_x(o), get_y(o))
else:
return get_length(o)
@@ -197,26 +197,12 @@ def get_footing_height(o: bpy.types.Object) -> float:
return get_height(o)
if predefined_type == "FOOTING_BEAM" or predefined_type == "STRIP_FOOTING":
return get_y(o)
elif predefined_type == "PAD_FOOTING" or predefined_type == "PILE_CAP":
elif predefined_type == "PAD_FOOTING":
return get_z(o)
else:
return get_height(o)
def get_footing_width(o: bpy.types.Object) -> float:
element = tool.Ifc.get_entity(o)
assert element
predefined_type = ifcopenshell.util.element.get_predefined_type(element)
if not predefined_type:
return get_width(o)
if predefined_type == "FOOTING_BEAM" or predefined_type == "STRIP_FOOTING":
return get_x(o)
elif predefined_type == "PAD_FOOTING" or predefined_type == "PILE_CAP":
return get_width(o)
else:
return get_width(o)
def get_height(o: bpy.types.Object) -> float:
"""_summary_: Returns the height of the object bounding box
@@ -239,7 +225,7 @@ def get_opening_depth(obj: bpy.types.Object) -> float:
if is_opening_horizontal(obj):
return get_height(obj)
else:
return get_y(obj)
return get_width(obj)
def get_opening_mapping_area(obj: bpy.types.Object) -> float:
+1 -8
View File
@@ -132,12 +132,5 @@ class SelectSimilarData:
if pset.endswith("Common"):
keys.extend([f'/.*Common/."{name}"' for name in properties.keys() if name != "id"])
else:
pset_part = f'"{pset}"' if " " in pset else pset
keys.extend(
[
f'{pset_part}."{name}"' if " " in name else f"{pset_part}.{name}"
for name in properties.keys()
if name != "id"
]
)
keys.extend([f"{pset}.{name}" for name in properties.keys() if name != "id"])
return [(k, k, "") for k in keys]
@@ -45,7 +45,6 @@ class SpatialTool(WorkSpaceTool):
("bim.spatial_hotkey", {"type": "T", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_T")]}),
("bim.spatial_hotkey", {"type": "G", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_G")]}),
("bim.spatial_hotkey", {"type": "H", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_H")]}),
("bim.spatial_hotkey", {"type": "Q", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_Q")]}),
)
def draw_settings(context, layout, ws_tool):
@@ -185,9 +184,3 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
def hotkey_S_G(self):
bpy.ops.bim.generate_space()
def hotkey_S_Q(self):
# Mirrors BimTool.hotkey_S_Q so quantities can be (re)calculated without switching tools.
if not bpy.context.selected_objects:
return
bpy.ops.bim.perform_quantity_take_off()
@@ -45,9 +45,7 @@ classes = (
operator.SelectByStyle,
operator.SelectStyleInStylesUI,
operator.SetAssetMaterialToExternalStyle,
operator.SuggestShadeFromExternalStyle,
operator.UnlinkStyle,
operator.TogglePreferIfcShading,
operator.UpdateCurrentStyle,
operator.UpdateStyleColours,
operator.UpdateStyleTextures,
+10 -229
View File
@@ -16,7 +16,6 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import colorsys
import os
from pathlib import Path
from typing import Any, Union
@@ -239,15 +238,13 @@ class UpdateCurrentStyle(bpy.types.Operator):
if not isinstance(obj.data, (bpy.types.Mesh, bpy.types.Curve)):
continue
for mat in obj.data.materials:
if not mat:
continue
msprops_ = tool.Style.get_material_style_props(mat)
if msprops_.ifc_definition_id == 0:
continue
if mat in updated_materials:
continue
msprops_.active_style_type = current_style_type
updated_materials.add(mat)
if (
mat
and mat not in updated_materials
and (msprops_ := tool.Style.get_material_style_props(mat)).ifc_definition_id != 0
):
msprops_.active_style_type = current_style_type
updated_materials.add(mat)
return {"FINISHED"}
@@ -460,14 +457,10 @@ class ActivateExternalStyle(bpy.types.Operator):
self.report({"ERROR"}, f"Error loading external style for \"{material.name}\" - {db['msg']}")
return {"CANCELLED"}
ext_mat = db["data_block"]
self.copy_material_attributes(ext_mat, material)
self.copy_material_attributes(db["data_block"], material)
if tool.Style.get_use_nodes(material):
if material.get("bim_dual_branch"):
tool.Style.update_external_branch(material, ext_mat)
else:
tool.Style.setup_dual_branch(material, ext_mat)
bpy.data.materials.remove(ext_mat)
tool.Blender.copy_node_graph(material, db["data_block"])
bpy.data.materials.remove(db["data_block"])
return {"FINISHED"}
def copy_material_attributes(self, source, target):
@@ -510,218 +503,6 @@ class ActivateExternalStyle(bpy.types.Operator):
set_prop(prop_name)
class TogglePreferIfcShading(bpy.types.Operator):
bl_idname = "bim.toggle_prefer_ifc_shading"
bl_label = "Toggle Flat/Pretty"
bl_description = (
"Toggle between Flat (IFC-native shading) and Pretty (external .blend style) for ALL styles.\n\n"
"SHIFT+CLICK to apply to this style only"
)
bl_options = {"REGISTER", "UNDO"}
material_name: bpy.props.StringProperty(name="Material Name", default="", options={"SKIP_SAVE"})
single_only: bpy.props.BoolProperty(name="Single Only", default=False, options={"SKIP_SAVE"})
def invoke(self, context, event):
if event.shift:
self.single_only = True
return self.execute(context)
def execute(self, context):
wm = context.window_manager
space = tool.Blender.get_view3d_space()
is_solid = space and space.shading.type == "SOLID"
if is_solid:
if space.shading.color_type == "TEXTURE":
space.shading.color_type = "MATERIAL"
else:
meshes_needing_uv = []
for obj in bpy.context.scene.objects:
if not isinstance(obj.data, bpy.types.Mesh):
continue
for slot in obj.material_slots:
mat = slot.material
if not mat or not tool.Blender.get_ifc_definition_id(mat):
continue
style_elements = tool.Style.get_style_elements(mat)
if style_elements.get("IfcSurfaceStyleWithTextures") and not obj.data.uv_layers:
meshes_needing_uv.append(obj.data)
break
wm.progress_begin(0, max(len(meshes_needing_uv), 1))
try:
for i, mesh in enumerate(meshes_needing_uv):
tool.Loader.load_generated_uv_map(mesh)
wm.progress_update(i)
finally:
wm.progress_end()
space.shading.color_type = "TEXTURE"
return {"FINISHED"}
if self.single_only:
mat = bpy.data.materials.get(self.material_name)
if not mat:
return {"CANCELLED"}
msprops = tool.Style.get_material_style_props(mat)
msprops.prefer_ifc_shading = not msprops.prefer_ifc_shading
else:
# Default: apply to all IFC materials
source_mat = bpy.data.materials.get(self.material_name)
new_value = not tool.Style.get_material_style_props(source_mat).prefer_ifc_shading if source_mat else True
ifc_mats = [m for m in bpy.data.materials if tool.Blender.get_ifc_definition_id(m)]
for mat in ifc_mats:
tool.Style.get_material_style_props(mat).prefer_ifc_shading = new_value
return {"FINISHED"}
class SuggestShadeFromExternalStyle(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.suggest_shade_from_external_style"
bl_label = "Suggest Shade from External Style"
bl_description = (
"Generate a Shade style (Surface Colour + Transparency) from the external .blend style.\n\n"
"ALT+CLICK to apply to all styles with an external .blend style"
)
bl_options = {"REGISTER", "UNDO"}
material_name: bpy.props.StringProperty(name="Material Name", default="", options={"SKIP_SAVE"})
all_styles: bpy.props.BoolProperty(name="All Styles", default=False, options={"SKIP_SAVE"})
value_offset: bpy.props.FloatProperty(
name="Value",
description="Offset added to the colour's value (-1 = fully dark, 0 = unchanged, +1 = fully light)",
default=0.0,
min=-1.0,
max=1.0,
step=1,
precision=2,
options={"SKIP_SAVE"},
)
saturation_factor: bpy.props.FloatProperty(
name="Saturation",
description="Scale applied to the colour's saturation (0 = greyscale, 1 = unchanged, >1 = more saturated)",
default=1.0,
min=0.0,
max=2.0,
step=1,
precision=2,
options={"SKIP_SAVE"},
)
def invoke(self, context, event):
if event.alt:
self.all_styles = True
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
layout = self.layout
layout.prop(self, "value_offset", slider=True)
layout.prop(self, "saturation_factor", slider=True)
if self.all_styles:
layout.label(text="Will apply to all external styles", icon="INFO")
def _execute(self, context):
if self.all_styles:
candidates = [
(mat, tool.Style.get_style_elements(mat))
for mat in bpy.data.materials
if tool.Blender.get_ifc_definition_id(mat)
]
candidates = [(mat, se) for mat, se in candidates if tool.Style.has_blender_external_style(se)]
wm = context.window_manager
wm.progress_begin(0, max(len(candidates), 1))
count = 0
color_cache: dict[tuple[str, str, str], tuple | None] = {}
try:
for i, (mat, style_elements) in enumerate(candidates):
wm.progress_update(i)
if self._apply_to_material(
mat, style_elements, self.value_offset, self.saturation_factor, color_cache
):
count += 1
finally:
wm.progress_end()
self.report({"INFO"}, f"Shade style generated for {count} style(s).")
else:
mat = bpy.data.materials.get(self.material_name)
if not mat:
return {"CANCELLED"}
style_elements = tool.Style.get_style_elements(mat)
if not tool.Style.has_blender_external_style(style_elements):
self.report({"ERROR"}, "No external .blend style assigned. Please assign an external style first.")
return {"CANCELLED"}
self._apply_to_material(mat, style_elements, self.value_offset, self.saturation_factor)
props = tool.Style.get_style_props()
if props.is_editing:
core.load_styles(tool.Style, style_type=props.style_type)
def _apply_to_material(
self,
material: bpy.types.Material,
style_elements: dict,
value_offset: float = 0.0,
saturation_factor: float = 1.0,
color_cache: "dict[tuple[str, str, str], tuple | None] | None" = None,
) -> bool:
external_style = style_elements["IfcExternallyDefinedSurfaceStyle"]
style_path = Path(tool.Ifc.resolve_uri(external_style.Location))
data_block_type, data_block = external_style.Identification.split("/")
cache_key = (str(style_path), data_block_type, data_block)
if color_cache is not None and cache_key in color_cache:
cached = color_cache[cache_key]
if cached is None:
return False # previously failed for this path
surface_colour, transparency = cached
else:
try:
db = tool.Blender.append_data_block(str(style_path), data_block_type, data_block)
except OSError as e:
self.report({"WARNING"}, f'Could not open blend file for "{material.name}": {e}')
if color_cache is not None:
color_cache[cache_key] = None
return False
if not db["data_block"]:
self.report({"WARNING"}, f'Could not load external style for "{material.name}": {db["msg"]}')
if color_cache is not None:
color_cache[cache_key] = None
return False
ext_mat = db["data_block"]
surface_colour, transparency = tool.Style.get_representative_material_color(ext_mat)
bpy.data.materials.remove(ext_mat)
if color_cache is not None:
color_cache[cache_key] = (surface_colour, transparency)
if value_offset != 0.0 or saturation_factor != 1.0:
h, s, v = colorsys.rgb_to_hsv(*surface_colour)
v = max(0.0, min(1.0, v + value_offset))
s = max(0.0, min(1.0, s * saturation_factor))
surface_colour = colorsys.hsv_to_rgb(h, s, v)
ifc_style = tool.Ifc.get_entity(material)
attributes: dict = {
"SurfaceColour": {
"Name": None,
"Red": surface_colour[0],
"Green": surface_colour[1],
"Blue": surface_colour[2],
},
}
if tool.Ifc.get_schema() != "IFC2X3":
attributes["Transparency"] = transparency
shading_style = style_elements.get("IfcSurfaceStyleShading")
if shading_style:
tool.Ifc.run("style.edit_surface_style", style=shading_style, attributes=attributes)
else:
tool.Ifc.run(
"style.add_surface_style",
style=ifc_style,
ifc_class="IfcSurfaceStyleShading",
attributes=attributes,
)
material.diffuse_color = (*surface_colour, 1.0 - transparency)
tool.Style.sync_flat_branch_shading(material, surface_colour, transparency)
return True
class DisableEditingStyles(bpy.types.Operator):
bl_idname = "bim.disable_editing_styles"
bl_options = {"REGISTER", "UNDO"}
@@ -372,16 +372,6 @@ def update_shading_style(self: "BIMStyleProperties", context: bpy.types.Context)
tool.Style.switch_shading(blender_material, self.active_style_type)
def update_prefer_ifc_shading(self: "BIMStyleProperties", context: bpy.types.Context) -> None:
style_elements = tool.Style.get_style_elements(self.id_data)
has_external = tool.Style.has_blender_external_style(style_elements)
if self.prefer_ifc_shading or not has_external:
self.active_style_type = "Shading"
else:
self.active_style_type = "External"
self.id_data.update_tag()
class BIMStyleProperties(PropertyGroup):
ifc_definition_id: IntProperty(name="IFC Definition ID")
active_style_type: EnumProperty(
@@ -391,19 +381,9 @@ class BIMStyleProperties(PropertyGroup):
default="Shading",
update=update_shading_style,
)
prefer_ifc_shading: BoolProperty(
name="Flat / Pretty",
description=(
"Toggle between Flat (IFC-native shading) and Pretty (external .blend style). "
"When set to Flat, viewport switches to Material Preview or Rendered will not activate the external style."
),
default=False,
update=update_prefer_ifc_shading,
)
is_renaming: BoolProperty(description="Used to prevent triggering handler callback.", default=False)
if TYPE_CHECKING:
ifc_definition_id: int
active_style_type: tool.Style.StyleType
prefer_ifc_shading: bool
is_renaming: bool
-65
View File
@@ -110,11 +110,6 @@ class BIM_PT_styles(Panel):
op = row.operator("bim.update_current_style", icon="FILE_REFRESH", text="")
op.style_id = style.ifc_definition_id
if active_style and self.props.style_type == "IfcSurfaceStyle":
if material := style.blender_material:
msprops = tool.Style.get_material_style_props(material)
self.draw_style_status_row(material, msprops)
if self.props.style_type == "IfcSurfaceStyle":
self.layout.label(text="Surface Style Element:")
col = self.layout.column(align=True)
@@ -166,66 +161,6 @@ class BIM_PT_styles(Panel):
edit_label = "Save Lighting Style"
self.draw_edit_ui(edit_label)
def draw_style_status_row(self, material: bpy.types.Material, msprops) -> None:
space = tool.Blender.get_view3d_space()
box = self.layout.box()
obj = bpy.context.active_object
parts = []
if space:
shading_type = space.shading.type
shading_labels = {
"SOLID": "Solid",
"MATERIAL": "Material Preview",
"RENDERED": "Rendered",
"WIREFRAME": "Wireframe",
}
parts.append(f"Viewport: {shading_labels.get(shading_type, shading_type)}")
else:
parts.append("No 3D viewport")
shading_type = None
if obj:
obj_has_uv = isinstance(obj.data, bpy.types.Mesh) and bool(obj.data.uv_layers)
uv_label = "UV \u2713" if obj_has_uv else "UV \u2717"
parts.append(f"Selected Object: {obj.name} {uv_label}")
else:
parts.append("Selected Object: None")
if shading_type == "SOLID":
is_flat = space.shading.color_type != "TEXTURE"
mode_label = "Flat"
dep_label = "Shade"
if not is_flat:
mode_label = "Pretty"
dep_label = "Texture \u2192 Shade"
elif shading_type in ("MATERIAL", "RENDERED"):
is_flat = msprops.prefer_ifc_shading
mode_label = "Flat"
dep_label = "Render+Texture \u2192 Render \u2192 Shade"
if not is_flat:
mode_label = "Pretty"
dep_label = "External \u2192 Render+Texture \u2192 Render \u2192 Shade"
else:
is_flat = False
mode_label = ""
dep_label = ""
row1 = box.row(align=True)
row1.label(text=" | ".join(parts))
if mode_label:
row2 = box.row(align=True)
row2.label(text=f"Current Mode: {mode_label} \u2014 {dep_label}")
row3 = box.row(align=True)
row3.alignment = "RIGHT"
op = row3.operator("bim.suggest_shade_from_external_style", text="", icon="BRUSHES_ALL")
op.material_name = material.name
op = row3.operator("bim.toggle_prefer_ifc_shading", text="", icon="UV_SYNC_SELECT")
op.material_name = material.name
def draw_surface_style_shading(self):
row = self.layout.row()
row.prop(self.props, "surface_colour")
-16
View File
@@ -41,7 +41,6 @@ import bonsai.bim.helper
import bonsai.tool as tool
from bonsai.bim.ifc import is_cache_locked_by_other_process
from bonsai.bim.module.bsdd.prop import BIMBSDDProperties, BSDDProperty
from bonsai.bim.module.material.operator import SelectByMaterial
from bonsai.bim.module.model import prop as _model_prop
from bonsai.bim.module.model import ui as _model_ui
from bonsai.bim.module.pset.prop import IfcProperty
@@ -1855,21 +1854,6 @@ def draw_statusbar(self, context):
def draw_custom_context_menu(self: bpy.types.Menu, context: bpy.types.Context) -> None:
# https://blender.stackexchange.com/a/275555/86891
# Context menu for material name buttons (e.g. `bim.select_by_material`),
# offering a quick "Rename Material" entry instead of having to look up
# the material in the scene Materials panel to rename it.
button_operator = getattr(context, "button_operator", None)
if button_operator is not None and button_operator.bl_rna.identifier == SelectByMaterial.bl_rna.identifier:
ifc_file = tool.Ifc.get()
material = ifc_file.by_id(button_operator.material) if ifc_file else None
if material is not None and material.is_a("IfcMaterial"):
assert self.layout
self.layout.separator()
op = self.layout.operator("bim.rename_material", text="Rename Material", icon="GREASEPENCIL")
op.material = material.id()
return
if (
not hasattr(context, "button_pointer")
or not hasattr(context, "button_prop")
-26
View File
@@ -520,7 +520,6 @@ def add_annotation(
relating_type: ifcopenshell.entity_instance,
enable_editing: bool = False,
) -> bpy.types.Object:
print(f"[SECTION] core.add_annotation called: object_type={object_type}")
target_view = drawing_tool.get_drawing_target_view(drawing)
context = drawing_tool.get_annotation_context(target_view, object_type)
if not context:
@@ -543,11 +542,6 @@ def add_annotation(
if relating_type:
drawing_tool.run_type_assign_type(element=element, relating_type=relating_type)
ifc.run("group.assign_group", group=drawing_tool.get_drawing_group(drawing), products=[element])
if object_type == "SECTION":
camera = ifc.get_object(drawing)
print(f"[SECTION] add_annotation: object_type=SECTION, camera={camera}")
if camera:
drawing_tool.update_section_endpoints(obj, camera)
if representation := drawing_tool.get_representation(element, context):
drawing_tool.reload_representation(obj=obj, representation=representation)
collector.assign(obj, should_clean_users_collection=True)
@@ -556,26 +550,6 @@ def add_annotation(
return obj
def assign_manual_drawing_reference(
ifc: type[tool.Ifc],
drawing_tool: type[tool.Drawing],
element: ifcopenshell.entity_instance,
drawing: Optional[ifcopenshell.entity_instance],
) -> None:
for existing in drawing_tool.get_assigned_product_workaround(element):
ifc.run("drawing.unassign_product", relating_product=existing, related_object=element)
if drawing:
ifc.run("drawing.assign_product", relating_product=drawing, related_object=element)
def assign_manual_reference_document(
drawing_tool: type[tool.Drawing],
element: ifcopenshell.entity_instance,
document: Optional[ifcopenshell.entity_instance],
) -> None:
drawing_tool.set_annotation_reference_doc(element, document)
def build_schedule(drawing: type[tool.Drawing], schedule: ifcopenshell.entity_instance) -> None:
drawing.create_svg_schedule(schedule)
drawing.open_svg(drawing.get_path_with_ext(drawing.get_document_uri(schedule), "svg"))
-9
View File
@@ -107,15 +107,6 @@ def disable_editing_material(material_tool: type[tool.Material]) -> None:
material_tool.disable_editing_material()
def rename_material(
ifc: type[tool.Ifc], material_tool: type[tool.Material], material: ifcopenshell.entity_instance, name: str
) -> None:
ifc.run("material.edit_material", material=material, attributes={"Name": name})
if material_tool.is_editing_materials():
material_tool.import_material_definitions(material_tool.get_active_material_type())
material_tool.refresh()
def assign_material(
ifc: type[tool.Ifc],
material_tool: type[tool.Material],
+1
View File
@@ -222,6 +222,7 @@ def generate_space(
if element and element.is_a("IfcSpace"):
spatial.set_space_representation_from_polygon(active_obj, element, space_polygon, h, polygon_is_si=True)
spatial.translate_obj_to_z_location(active_obj, z)
else:
if relating_type:
name = model.generate_occurrence_name(relating_type, "IfcSpace")
-1
View File
@@ -667,7 +667,6 @@ class Material:
def is_editing_materials(cls): pass
def is_material_used_in_sets(cls, material): pass
def load_material_attributes(cls, material): pass
def refresh(cls): pass
def replace_material_with_material_profile(cls, element): pass
def update_elements_using_material(cls, material): pass
-34
View File
@@ -803,40 +803,6 @@ class Blender(bonsai.core.tool.Blender):
# restore shader editor settings
shader_editor.pin = previous_pin_setting
@classmethod
def copy_node_graph_additive(
cls, material_to: bpy.types.Material, material_from: bpy.types.Material
) -> bpy.types.ShaderNodeOutputMaterial | None:
"""Paste nodes from material_from alongside the existing nodes in material_to.
Unlike copy_node_graph this does NOT clear the existing node tree first.
Returns the OUTPUT_MATERIAL node that was added from material_from, or None.
"""
temp_override = cls.get_shader_editor_context()
shader_editor = temp_override["space"]
before_names = {n.name for n in material_to.node_tree.nodes}
previous_pin_setting = shader_editor.pin
shader_editor.pin = True
shader_editor.node_tree = material_from.node_tree
for node in material_from.node_tree.nodes:
node.select = True
with bpy.context.temp_override(**temp_override):
bpy.ops.node.clipboard_copy()
shader_editor.node_tree = material_to.node_tree
with bpy.context.temp_override(**temp_override):
bpy.ops.node.clipboard_paste(offset=(0, 0))
shader_editor.pin = previous_pin_setting
for node in material_to.node_tree.nodes:
if node.name not in before_names and node.type == "OUTPUT_MATERIAL":
return node
return None
@classmethod
def get_material_node(
cls, blender_material: bpy.types.Material, node_type: str, kwargs: Optional[dict] = {}
+1
View File
@@ -138,6 +138,7 @@ class Bsdd(bonsai.core.tool.Bsdd):
def get_dictionaries(cls) -> list[bsdd.DictionaryContractV1]:
prefs = tool.Blender.get_addon_preferences()
baseurl = getattr(prefs, "bsdd_baseurl", "https://api.bsdd.buildingsmart.org/api/")
cls.client = bsdd.Client()
if hasattr(cls.client, "baseurl"):
cls.client.baseurl = baseurl
response = cls.client.get_dictionary(include_test_dictionaries=prefs.bsdd_load_test_dictionaries)
-11
View File
@@ -1270,17 +1270,6 @@ class ClipBox:
def on_depsgraph_update_caps(cls, scene, depsgraph) -> None:
"""Depsgraph entry-point — guard, then delegate to the
modal-aware debounce in :meth:`_handle_cap_tick`."""
# Same file-load danger window as on_depsgraph_update: a real
# depsgraph tick during load (between load_pre and the new file's
# first paint) must not re-arm a cap-rebuild timer. _on_load_pre
# already cancels any in-flight timer via _cancel_pending_cap_rebuild;
# without this gate, a depsgraph_update_post event firing later in
# the same load (Blender fires these while building the new file's
# scene) would immediately reschedule one via _handle_cap_tick,
# undoing that cancellation and re-arming against regions whose GPU
# state is not yet wired.
if cls._file_loading:
return
if getattr(bpy.context, "screen", None) is None:
return
if cls._active_scene_props(scene) is None:
+1 -325
View File
@@ -77,9 +77,6 @@ if TYPE_CHECKING:
from bonsai.bim.module.drawing.prop import Drawing as DrawingProperties
print("[SECTION] tool/drawing.py module loaded")
class Drawing(bonsai.core.tool.Drawing):
ANNOTATION_DATA_TYPE = Literal["empty", "curve", "mesh"]
PERSPECTIVE_CAMERA_SHIFT_PROPERTIES = ("PerspectiveShiftX", "PerspectiveShiftY")
@@ -212,17 +209,6 @@ class Drawing(bonsai.core.tool.Drawing):
co_end = co1 + vec * scaled_length
obj = annotation.Annotator.add_line_to_annotation(obj, co_end, co1)
obj.matrix_world = obj.matrix_world @ Matrix.Rotation(math.radians(-90), 4, "Z")
elif object_type == "SECTION_LEVEL":
co1, _, co3, _ = annotation.Annotator.get_placeholder_coords()
# co3 - co1 is the camera X direction (horizontal in a section view).
vec = co3 - co1
if vec.length == 0:
vec = Vector((1, 0, 0))
else:
vec = vec.normalized()
scaled_length = 0.023 * scale
co_end = co1 + vec * scaled_length
obj = annotation.Annotator.add_line_to_annotation(obj, co_end, co1)
elif object_type != "TEXT":
obj = annotation.Annotator.add_line_to_annotation(obj)
@@ -1646,14 +1632,7 @@ class Drawing(bonsai.core.tool.Drawing):
@classmethod
def is_auto_annotation(cls, element: ifcopenshell.entity_instance):
if not (element.is_a("IfcAnnotation") and element.ObjectType in ("GRID", "SECTION", "ELEVATION", "SECTION_LEVEL")):
return False
if ifcopenshell.util.element.get_pset(element, "EPset_Annotation", "IsManualDrawingReference"):
return False
ptype = ifcopenshell.util.element.get_predefined_type(element)
if ptype in ("SECTION_LEVEL", "PLAN_LEVEL") and ifcopenshell.util.element.get_pset(element, "BBIM_Dimension"):
return False
return True
return element.is_a("IfcAnnotation") and element.ObjectType in ("GRID", "SECTION", "ELEVATION", "SECTION_LEVEL")
@classmethod
def get_drawing_reference_annotation(
@@ -1985,95 +1964,6 @@ class Drawing(bonsai.core.tool.Drawing):
element.Name = elevation.Name or "Unnamed"
return element
@classmethod
def create_manual_elevation_reference(cls, drawing: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
cursor_location = bpy.context.scene.cursor.location.copy()
obj = bpy.data.objects.new("Unnamed", None)
obj.empty_display_size = 0.1
obj.matrix_world = Matrix.Translation(cursor_location) @ Matrix.Rotation(math.radians(90), 4, "X")
element = cls.run_root_assign_class(
obj=obj, ifc_class="IfcAnnotation", predefined_type="ELEVATION", should_add_representation=False
)
element.Name = "Unnamed"
return element
@classmethod
def create_manual_section_reference(
cls, drawing: ifcopenshell.entity_instance, context: ifcopenshell.entity_instance
) -> ifcopenshell.entity_instance:
cursor_location = bpy.context.scene.cursor.location.copy()
mesh = bpy.data.meshes.new("Mesh")
obj = bpy.data.objects.new("Unnamed", mesh)
obj.matrix_world = Matrix.Translation(cursor_location)
element = cls.run_root_assign_class(
obj=obj, ifc_class="IfcAnnotation", predefined_type="SECTION", should_add_representation=False
)
element.Name = "Unnamed"
builder = ShapeBuilder(tool.Ifc.get())
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
p1 = cursor_location + Vector((-0.5, 0, 0))
p2 = cursor_location + Vector((0.5, 0, 0))
points = [p1 / unit_scale, p2 / unit_scale]
representation = builder.get_representation(context, [builder.polyline(points)])
ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element, representation)
bonsai.core.geometry.switch_representation(tool.Ifc, tool.Geometry, obj=obj, representation=representation)
return element
@classmethod
def set_manual_drawing_reference(cls, element: ifcopenshell.entity_instance) -> None:
ifc_file = tool.Ifc.get()
pset = tool.Pset.get_element_pset(element, "EPset_Annotation")
if not pset:
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="EPset_Annotation")
ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties={"IsManualDrawingReference": True})
@classmethod
def is_manual_drawing_reference(cls, element: ifcopenshell.entity_instance) -> bool:
return bool(ifcopenshell.util.element.get_pset(element, "EPset_Annotation", "IsManualDrawingReference"))
@classmethod
def is_document_reference(cls, element: ifcopenshell.entity_instance) -> bool:
"""Return True if this annotation links to an external document (not a Bonsai drawing camera)."""
return bool(ifcopenshell.util.element.get_pset(element, "EPset_Annotation", "IsDocumentReference"))
@classmethod
def set_document_reference_flag(cls, element: ifcopenshell.entity_instance) -> None:
"""Mark this annotation as pointing to an external document reference."""
ifc_file = tool.Ifc.get()
pset = tool.Pset.get_element_pset(element, "EPset_Annotation")
if not pset:
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="EPset_Annotation")
ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties={"IsDocumentReference": True})
@classmethod
def get_annotation_reference_doc(
cls, element: ifcopenshell.entity_instance
) -> Union[ifcopenshell.entity_instance, None]:
"""Return the IfcDocumentInformation linked to a document-reference annotation."""
for rel in element.HasAssociations:
if rel.is_a("IfcRelAssociatesDocument"):
doc = rel.RelatingDocument
if doc.is_a("IfcDocumentInformation"):
return doc
return None
@classmethod
def set_annotation_reference_doc(
cls,
element: ifcopenshell.entity_instance,
document: Union[ifcopenshell.entity_instance, None],
) -> None:
"""Associate (or clear) an IfcDocumentInformation on a document-reference annotation."""
ifc_file = tool.Ifc.get()
# Remove existing document associations on this annotation.
for rel in list(element.HasAssociations):
if rel.is_a("IfcRelAssociatesDocument"):
ifcopenshell.api.document.unassign_document(
ifc_file, products=[element], document=rel.RelatingDocument
)
if document:
ifcopenshell.api.document.assign_document(ifc_file, products=[element], document=document)
@classmethod
def regenerate_elevation_reference_annotation(
cls,
@@ -2920,176 +2810,6 @@ class Drawing(bonsai.core.tool.Drawing):
numerator, denominator = scale.split("/")
return float(numerator) / float(denominator)
@classmethod
def get_camera_dimensions(cls, camera: bpy.types.Object) -> tuple[float, float]:
render = bpy.context.scene.render
assert isinstance(camera.data, bpy.types.Camera)
if render.resolution_x > render.resolution_y:
width = camera.data.ortho_scale
height = width / render.resolution_x * render.resolution_y
else:
height = camera.data.ortho_scale
width = height / render.resolution_y * render.resolution_x
return width, height
@staticmethod
def _section_ray_rect_intersections(
origin: Vector, direction: Vector, half_w: float, half_h: float
) -> list[float]:
"""Return t values where the ray origin+t*direction intersects the ±half_w/±half_h rectangle."""
results: list[float] = []
eps = 1e-6
if abs(direction.x) > eps:
for x_bound in (-half_w, half_w):
t = (x_bound - origin.x) / direction.x
if abs(origin.y + t * direction.y) <= half_h + eps:
results.append(t)
if abs(direction.y) > eps:
for y_bound in (-half_h, half_h):
t = (y_bound - origin.y) / direction.y
if abs(origin.x + t * direction.x) <= half_w + eps:
results.append(t)
return results
@classmethod
def get_section_border_positions(
cls,
camera: bpy.types.Object,
v0_world: Vector,
v1_world: Vector,
border_offset_mm: float,
) -> tuple[Vector, Vector]:
"""Return world-space positions for section endpoints placed at the camera border + border_offset_mm (paper mm)."""
diagram_scale = cls.get_diagram_scale(camera)
if not diagram_scale:
print("[SECTION] get_section_border_positions: no diagram_scale, returning original")
return v0_world, v1_world
scale = cls.get_scale_ratio(diagram_scale["Scale"])
model_offset = (border_offset_mm / 1000.0) / scale
print(f"[SECTION] scale={scale}, border_offset_mm={border_offset_mm}, model_offset={model_offset:.4f}m")
width, height = cls.get_camera_dimensions(camera)
half_w, half_h = width / 2, height / 2
print(f"[SECTION] camera dims: width={width:.3f}, height={height:.3f}, half_w={half_w:.3f}, half_h={half_h:.3f}")
cam_inv = camera.matrix_world.inverted()
v0_local = cam_inv @ v0_world
v1_local = cam_inv @ v1_world
print(f"[SECTION] v0_local={v0_local}, v1_local={v1_local}")
origin = Vector(((v0_local.x + v1_local.x) / 2, (v0_local.y + v1_local.y) / 2))
dir_xy = Vector((v1_local.x - v0_local.x, v1_local.y - v0_local.y))
if dir_xy.length < 1e-6:
print("[SECTION] get_section_border_positions: degenerate edge, returning original")
return v0_world, v1_world
dir_xy = dir_xy.normalized()
z = v0_local.z
print(f"[SECTION] origin={origin}, dir_xy={dir_xy}, z={z:.4f}")
t_values = cls._section_ray_rect_intersections(origin, dir_xy, half_w, half_h)
print(f"[SECTION] ray-rect t_values={t_values}")
pos_ts = sorted(t for t in t_values if t >= 0)
neg_ts = sorted((t for t in t_values if t < 0), reverse=True)
print(f"[SECTION] pos_ts={pos_ts}, neg_ts={neg_ts}")
if not pos_ts or not neg_ts:
print("[SECTION] get_section_border_positions: no valid border intersections, returning original")
return v0_world, v1_world
t_end = pos_ts[0]
t_start = neg_ts[0]
new_v0_local = Vector((
origin.x + (t_start + model_offset) * dir_xy.x,
origin.y + (t_start + model_offset) * dir_xy.y,
z,
))
new_v1_local = Vector((
origin.x + (t_end - model_offset) * dir_xy.x,
origin.y + (t_end - model_offset) * dir_xy.y,
z,
))
return camera.matrix_world @ new_v0_local, camera.matrix_world @ new_v1_local
@classmethod
def update_section_endpoints(cls, obj: bpy.types.Object, camera: bpy.types.Object) -> None:
"""Move section line endpoints to camera border + BorderOffset, skipping any manually moved vertex."""
print(f"[SECTION] update_section_endpoints called: obj={obj.name}, camera={camera.name}")
element = tool.Ifc.get_entity(obj)
if not element:
print("[SECTION] SKIP: no IFC element on obj")
return
if not obj.data or not hasattr(obj.data, "edges") or not obj.data.edges:
print("[SECTION] SKIP: obj has no mesh edges")
return
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Section") or {}
border_offset = float(pset_data.get("BorderOffset", 8.0))
print(f"[SECTION] pset_data={pset_data}, border_offset={border_offset}")
if border_offset <= 0:
print("[SECTION] SKIP: BorderOffset <= 0")
return
auto_v0 = cls._parse_vector3(pset_data.get("AutoStartPosition") or "")
auto_v1 = cls._parse_vector3(pset_data.get("AutoEndPosition") or "")
print(f"[SECTION] stored auto_v0={auto_v0}, auto_v1={auto_v1}")
edge = obj.data.edges[0]
v0 = obj.data.vertices[edge.vertices[0]]
v1 = obj.data.vertices[edge.vertices[1]]
v0_world = obj.matrix_world @ v0.co
v1_world = obj.matrix_world @ v1.co
print(f"[SECTION] current v0_world={v0_world}, v1_world={v1_world}")
# A vertex is "auto" if it has never been auto-positioned, or still sits at the stored auto position.
v0_is_auto = auto_v0 is None or (v0_world - auto_v0).length < 1e-4
v1_is_auto = auto_v1 is None or (v1_world - auto_v1).length < 1e-4
print(f"[SECTION] v0_is_auto={v0_is_auto}, v1_is_auto={v1_is_auto}")
if not v0_is_auto and not v1_is_auto:
print("[SECTION] SKIP: both vertices are manually overridden")
return
new_v0_world, new_v1_world = cls.get_section_border_positions(camera, v0_world, v1_world, border_offset)
print(f"[SECTION] new_v0_world={new_v0_world}, new_v1_world={new_v1_world}")
if v0_is_auto:
v0.co = obj.matrix_world.inverted() @ new_v0_world
if v1_is_auto:
v1.co = obj.matrix_world.inverted() @ new_v1_world
obj.data.update()
stored_v0 = new_v0_world if v0_is_auto else v0_world
stored_v1 = new_v1_world if v1_is_auto else v1_world
pset_id = pset_data.get("id")
if pset_id:
pset_entity = tool.Ifc.get().by_id(pset_id)
else:
pset_entity = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), product=element, name="BBIM_Section")
ifcopenshell.api.pset.edit_pset(
tool.Ifc.get(),
pset=pset_entity,
properties={
"BorderOffset": border_offset,
"AutoStartPosition": cls._format_vector3(stored_v0),
"AutoEndPosition": cls._format_vector3(stored_v1),
},
)
bpy.ops.bim.update_representation(obj=obj.name, ifc_representation_class="")
print(f"[SECTION] done. stored auto_v0={cls._format_vector3(stored_v0)}, auto_v1={cls._format_vector3(stored_v1)}")
@staticmethod
def _parse_vector3(s: str) -> Optional[Vector]:
try:
x, y, z = map(float, s.split(","))
return Vector((x, y, z))
except Exception:
return None
@staticmethod
def _format_vector3(v: Vector) -> str:
return f"{v.x:.6f},{v.y:.6f},{v.z:.6f}"
@classmethod
def get_diagram_scale(cls, camera: Union[bpy.types.Object, bpy.types.Camera]) -> dict[str, str]:
props = cls.get_camera_props(camera)
@@ -3167,50 +2887,6 @@ class Drawing(bonsai.core.tool.Drawing):
break
return sheet_references
@classmethod
def get_sheeted_drawing_ids(cls) -> set[int]:
"""Get the IFC ids of all drawings that are placed on at least one sheet."""
ifc_file = tool.Ifc.get()
sheet_locations: set[Union[str, None]] = set()
for sheet in ifc_file.by_type("IfcDocumentInformation"):
if sheet.Scope != "SHEET":
continue
for reference in cls.get_document_references(sheet):
sheet_locations.add(reference.Location)
if not sheet_locations:
return set()
result: set[int] = set()
for drawing in ifc_file.by_type("IfcAnnotation"):
if drawing.ObjectType != "DRAWING":
continue
drawing_document = cls.get_drawing_document(drawing)
if drawing_document and drawing_document.Location in sheet_locations:
result.add(drawing.id())
return result
@classmethod
def get_visible_drawings_in_category(cls, target_view: str) -> list[DrawingProperties]:
"""Get the drawing items in a target view category that are currently visible in the drawing list.
Grouping is positional: individual drawing items don't carry their own ``target_view``, they belong to
the most recent header item above them. Only expanded categories contribute drawing items to the
collection, so a collapsed category yields an empty list. Respects the ``show_drawings_on_sheets_only``
filter so that select-all only affects visible drawings.
"""
props = cls.get_document_props()
drawings: list[DrawingProperties] = []
in_category = False
for item in props.drawings:
if not item.is_drawing:
# Header row: we're inside the requested category until the next header.
in_category = item.target_view == target_view
elif in_category:
drawings.append(item)
if props.show_drawings_on_sheets_only:
sheeted_ids = cls.get_sheeted_drawing_ids()
drawings = [d for d in drawings if d.ifc_definition_id in sheeted_ids]
return drawings
@classmethod
def get_camera_matrix(cls, camera: bpy.types.Object) -> Matrix:
matrix_world = camera.matrix_world.copy().normalized()
-6
View File
@@ -146,12 +146,6 @@ class Material(bonsai.core.tool.Material):
MaterialsData.data["material_styles_data"] = MaterialsData.material_styles_data()
@classmethod
def refresh(cls) -> None:
from bonsai.bim.module.material.data import refresh as refresh_material_data
refresh_material_data()
@classmethod
def is_editing_materials(cls) -> bool:
props = tool.Material.get_material_props()
+1 -9
View File
@@ -47,15 +47,7 @@ class Nest(bonsai.core.tool.Nest):
return False
if relating_object == related_object:
return False
# IfcRelNests.RelatingObject/RelatedObjects are typed as the general
# IfcObjectDefinition, so nesting is schema-legal both between element
# occurrences (the common case, e.g. a faucet nested into a sink) and
# between element types (e.g. an assembly type nesting its component
# types). Mixing an occurrence with a type isn't a real modeling
# pattern, so only allow same-kind pairs. See #2283.
is_compatible_class = (relating_object.is_a("IfcElement") and related_object.is_a("IfcElement")) or (
relating_object.is_a("IfcTypeProduct") and related_object.is_a("IfcTypeProduct")
)
is_compatible_class = relating_object.is_a("IfcElement") and related_object.is_a("IfcElement")
if not is_compatible_class:
return False
# Prevent cyclic references: walk up the full hierarchy from the
+3 -3
View File
@@ -168,12 +168,12 @@ class Polyline(bonsai.core.tool.Polyline):
distance = (mouse_vector - last_point).length
if distance < 0:
return
angle, orientation_angle = None, None
angle_round_threshold = 1000 # Avoids rounding when distance is too big
angle, orientation_angle, angle_round_threshold = None, None, None
if distance > 0:
angle = tool.Cad.angle_3_vectors(
second_to_last_point, last_point, mouse_vector, new_angle=None, degrees=True
)
angle_round_threshold = 1000 # Avoids rounding when distance is too big
# Round angle to the nearest 0.05
angle = round(angle / 0.05) * 0.05 if distance < angle_round_threshold else angle
@@ -189,7 +189,7 @@ class Polyline(bonsai.core.tool.Polyline):
angle = 0
orientation_angle = 0
if input_ui:
assert angle is not None and orientation_angle is not None
assert angle is not None and orientation_angle is not None and angle_round_threshold is not None
if should_round:
angle_snap = tool.Snap.get_angle_snap_value(context)
angle = angle_snap * round(angle / angle_snap) if distance < angle_round_threshold else angle
+17 -2
View File
@@ -391,14 +391,29 @@ class Project(bonsai.core.tool.Project):
def get_parent_library(
cls, project_library: ifcopenshell.entity_instance
) -> Union[ifcopenshell.entity_instance, None]:
"""Return the IfcContext that declares or nests ``project_library``, or ``None``
if neither relationship is present."""
"""Return the IfcContext that declares or nests ``project_library``.
Returns ``None`` when ``project_library`` is itself the root of a
library-only file (no IfcRelNests, no IfcRelDeclares).
"""
if nests := project_library.Nests:
return nests[0].RelatingObject
if has_context := project_library.HasContext:
return has_context[0].RelatingContext
return None
@classmethod
def get_root_context(cls, ifc_file: ifcopenshell.file) -> ifcopenshell.entity_instance:
"""Return the file's root IfcContext.
Prefers IfcProject if present, otherwise falls back to IfcProjectLibrary
library-only files are valid per IFC4+ and contain no IfcProject. Caller is
responsible for the IFC2X3 guard; IfcContext does not exist in that schema.
"""
if projects := ifc_file.by_type("IfcProject"):
return projects[0]
return ifc_file.by_type("IfcProjectLibrary")[0]
@classmethod
def get_project_hierarchy(cls, ifc_file: ifcopenshell.file) -> HiearchyDict:
"""Get project hierarchy in the following form:
+5 -19
View File
@@ -970,31 +970,18 @@ class Raycast(bonsai.core.tool.Raycast):
if obj.data is None or not isinstance(obj.data, bpy.types.Mesh):
return None
for i, snap_obj in enumerate(cls.snap_objs):
try:
cached_name = snap_obj.obj.name
except ReferenceError:
cls.snap_objs.pop(i)
break
if obj.name == cached_name:
# Fast O(1) invalidation: vertex count change (mesh edit) or
# world matrix change (object moved/rotated).
if obj.name == snap_obj.obj.name:
# Handle objects modified while a modal operator is active.
# Example: adding a door or window alters the wall geometry.
if len(obj.data.vertices) != len(snap_obj.verts_3d):
cls.snap_objs.pop(i)
snap_obj = SnapObj(obj)
cls.snap_objs.append(snap_obj)
return snap_obj
if obj.matrix_world != snap_obj.matrix_world:
cls.snap_objs.pop(i)
snap_obj = SnapObj(obj)
cls.snap_objs.append(snap_obj)
return snap_obj
# Sample one vertex to catch mesh edits that preserve vertex count.
if obj.data.vertices and snap_obj.verts_3d:
if (obj.matrix_world @ obj.data.vertices[0].co) != snap_obj.verts_3d[0]:
for v1, v2 in zip(obj.data.vertices, snap_obj.verts_3d):
if (obj.matrix_world @ v1.co) != v2:
cls.snap_objs.pop(i)
snap_obj = SnapObj(obj)
cls.snap_objs.append(snap_obj)
return snap_obj
return snap_obj
snap_obj = SnapObj(obj)
cls.snap_objs.append(snap_obj)
@@ -1033,7 +1020,6 @@ class SnapObj:
self.root = None
self._bvh_built = False
self.verts_3d = [obj.matrix_world @ v.co for v in obj.data.vertices]
self.matrix_world = obj.matrix_world.copy()
self.snap_points = []
def _ensure_bvh(self):
-363
View File
@@ -597,146 +597,6 @@ class Style(bonsai.core.tool.Style):
external_style = style_elements.get("IfcExternallyDefinedSurfaceStyle", None)
return bool(external_style and external_style.Location and external_style.Location.endswith(".blend"))
@classmethod
def _color_from_principled(cls, node: bpy.types.Node) -> tuple[tuple[float, float, float], float]:
color = cls._resolve_color_socket(node.inputs["Base Color"])
alpha_socket = node.inputs["Alpha"]
alpha_source = cls._upstream_color_source(alpha_socket)
if alpha_source and alpha_source[0] == "IMAGE":
pixels = alpha_source[1].pixels[:]
n = len(pixels) // 4
step = max(1, n // 4096)
a_sum = sum(pixels[i * 4 + 3] for i in range(0, n, step))
count = len(range(0, n, step)) or 1
transparency = 1.0 - (a_sum / count)
else:
transparency = 1.0 - alpha_socket.default_value
return color, transparency
@classmethod
def _color_from_shader_socket(
cls, socket: bpy.types.NodeSocket, seen: set[str] | None = None
) -> tuple[tuple[float, float, float], float] | None:
if seen is None:
seen = set()
for link in socket.links:
node = link.from_node
if node.name in seen:
continue
seen.add(node.name)
if node.type == "BSDF_PRINCIPLED":
return cls._color_from_principled(node)
if node.type in ("BSDF_DIFFUSE", "DIFFUSE_BSDF"):
return cls._resolve_color_socket(node.inputs["Color"]), 0.0
if node.type == "BSDF_GLASS":
return cls._resolve_color_socket(node.inputs["Color"]), 0.0
if node.type in ("MIX_SHADER", "ADD_SHADER"):
for inp in node.inputs:
if inp.type == "SHADER" and inp.is_linked:
result = cls._color_from_shader_socket(inp, seen)
if result:
return result
return None
@classmethod
def get_representative_material_color(
cls, material: bpy.types.Material
) -> tuple[tuple[float, float, float], float]:
if material.node_tree:
nodes = material.node_tree.nodes
output_node = next((n for n in nodes if n.type == "OUTPUT_MATERIAL" and n.is_active_output), None) or next(
(n for n in nodes if n.type == "OUTPUT_MATERIAL"), None
)
if output_node:
result = cls._color_from_shader_socket(output_node.inputs["Surface"])
if result:
return result
# Fallback: scan all shader nodes if no output node or graph traversal found nothing
for node in nodes:
if node.type == "BSDF_PRINCIPLED":
return cls._color_from_principled(node)
for node in nodes:
if node.type in ("BSDF_DIFFUSE", "DIFFUSE_BSDF"):
return cls._resolve_color_socket(node.inputs["Color"]), 0.0
for node in nodes:
if node.type == "BSDF_GLASS":
return cls._resolve_color_socket(node.inputs["Color"]), 0.0
color = tuple(material.diffuse_color[:3])
transparency = 1.0 - material.diffuse_color[3]
return color, transparency
@classmethod
def _collect_upstream_sources(cls, socket: bpy.types.NodeSocket, seen: set[str]) -> list[tuple[str, object]]:
"""Recursively collect all upstream colour/image sources reachable from *socket*."""
results = []
for link in socket.links:
node = link.from_node
if node.name in seen:
continue
seen.add(node.name)
if node.type == "TEX_IMAGE":
results.append(("IMAGE", node.image))
elif node.type == "VALTORGB":
results.append(("COLORRAMP", node))
else:
for inp in node.inputs:
if inp.is_linked:
results.extend(cls._collect_upstream_sources(inp, seen))
return results
@classmethod
def _upstream_color_source(
cls, socket: bpy.types.NodeSocket, seen: set[str] | None = None
) -> tuple[str, object] | None:
sources = cls._collect_upstream_sources(socket, set() if seen is None else seen)
# Prefer a concrete image texture over a colour ramp (which may be greyscale/procedural).
for s in sources:
if s[0] == "IMAGE":
return s
for s in sources:
if s[0] == "COLORRAMP":
return s
return None
@classmethod
def _resolve_color_socket(cls, socket: bpy.types.NodeSocket) -> tuple[float, float, float]:
source = cls._upstream_color_source(socket)
if source is None:
return tuple(socket.default_value[:3])
kind, obj = source
if kind == "IMAGE":
return cls._average_image_color(obj)
if kind == "COLORRAMP":
return cls._average_colorramp_color(obj)
return tuple(socket.default_value[:3])
@staticmethod
def _average_image_color(image: bpy.types.Image) -> tuple[float, float, float]:
pixels = image.pixels[:]
n = len(pixels) // 4
if n == 0:
return (0.5, 0.5, 0.5)
step = max(1, n // 4096)
r_sum = g_sum = b_sum = 0.0
count = 0
for i in range(0, n, step):
base = i * 4
r_sum += pixels[base]
g_sum += pixels[base + 1]
b_sum += pixels[base + 2]
count += 1
return (r_sum / count, g_sum / count, b_sum / count)
@staticmethod
def _average_colorramp_color(node: bpy.types.Node) -> tuple[float, float, float]:
elements = node.color_ramp.elements
if not elements:
return (0.5, 0.5, 0.5)
r = sum(e.color[0] for e in elements) / len(elements)
g = sum(e.color[1] for e in elements) / len(elements)
b = sum(e.color[2] for e in elements) / len(elements)
return (r, g, b)
@classmethod
def is_editing_styles(cls) -> bool:
props = cls.get_style_props()
@@ -814,179 +674,9 @@ class Style(bonsai.core.tool.Style):
props = cls.get_material_style_props(blender_material)
props.active_style_type = props.active_style_type
@classmethod
def get_branch_outputs(
cls, material: bpy.types.Material
) -> tuple[bpy.types.ShaderNode | None, bpy.types.ShaderNode | None]:
"""Return (external_output_node, flat_output_node), or (None, None) if not dual-branch."""
if not material.node_tree:
return None, None
ext = material.node_tree.nodes.get("BIM_Output_External")
fast = material.node_tree.nodes.get("BIM_Output_Flat")
return ext, fast
@classmethod
def _remove_external_branch(cls, material: bpy.types.Material) -> None:
"""Remove all nodes reachable from BIM_Output_External (walks links backwards)."""
if not material.node_tree:
return
nodes = material.node_tree.nodes
output = nodes.get("BIM_Output_External")
if not output:
return
to_remove: set[str] = set()
stack = [output]
while stack:
node = stack.pop()
if node.name in to_remove:
continue
to_remove.add(node.name)
for inp in node.inputs:
for link in inp.links:
stack.append(link.from_node)
for name in list(to_remove):
n = nodes.get(name)
if n:
nodes.remove(n)
@classmethod
def _build_flat_branch_nodes(cls, material: bpy.types.Material) -> bpy.types.ShaderNode:
"""Add a Principled BSDF flat-branch to material's existing node tree.
Reads IfcSurfaceStyleRendering or IfcSurfaceStyleShading from the linked IFC entity.
Defaults to a white BSDF when no IFC shading data is available.
Returns the new Material Output node (named BIM_Output_Flat, is_active_output=False).
"""
from mathutils import Vector
style_elements = cls.get_style_elements(material)
nodes = material.node_tree.nodes
links = material.node_tree.links
bsdf = nodes.new("ShaderNodeBsdfPrincipled")
bsdf.location = Vector((10, -600))
output = nodes.new("ShaderNodeOutputMaterial")
output.name = "BIM_Output_Flat"
output.location = Vector((300, -600))
output.is_active_output = False
links.new(bsdf.outputs["BSDF"], output.inputs["Surface"])
rendering_style = None
shading_only = None
for surface_style in style_elements.values():
if surface_style.is_a() == "IfcSurfaceStyleShading":
shading_only = surface_style
elif surface_style.is_a("IfcSurfaceStyleRendering"):
rendering_style = surface_style
shading_only = None
if rendering_style:
d = tool.Loader.surface_style_to_dict(rendering_style)
if d.get("DiffuseColour"):
ctype, cval = d["DiffuseColour"]
if ctype == "IfcColourRgb":
bsdf.inputs["Base Color"].default_value = cval + (1,)
solid_color = cval
else:
cval = tuple(v * cval for v in d["SurfaceColour"])
bsdf.inputs["Base Color"].default_value = cval + (1,)
solid_color = cval
else:
r, g, b = d["SurfaceColour"]
bsdf.inputs["Base Color"].default_value = (r, g, b, 1.0)
solid_color = (r, g, b)
if d.get("SpecularColour"):
ctype, cval = d["SpecularColour"]
if ctype == "IfcNormalisedRatioMeasure":
bsdf.inputs["Metallic"].default_value = cval
if d.get("SpecularHighlight"):
bsdf.inputs["Roughness"].default_value = d["SpecularHighlight"]
transparency = d.get("Transparency") or 0.0
bsdf.inputs["Alpha"].default_value = 1 - transparency
if transparency > 0:
material.blend_method = "BLEND"
material.diffuse_color = solid_color + (1.0 - transparency,)
elif shading_only:
d = tool.Loader.surface_style_to_dict(shading_only)
r, g, b = d["SurfaceColour"]
alpha = 1 - (d.get("Transparency") or 0.0)
bsdf.inputs["Base Color"].default_value = (r, g, b, 1.0)
bsdf.inputs["Alpha"].default_value = alpha
if alpha < 1.0:
material.blend_method = "BLEND"
material.diffuse_color = (r, g, b, alpha)
# else: leave default white Principled BSDF
return output
@classmethod
def setup_dual_branch(cls, material: bpy.types.Material, ext_material: bpy.types.Material) -> bool:
"""Build a dual-branch node tree: flat branch from IFC data + external branch from ext_material.
Clears any existing nodes and builds both branches from scratch.
External branch output (BIM_Output_External) is set active Pretty mode.
Flat branch output (BIM_Output_Flat) is inactive Flat mode.
Returns True on success; False if no shader editor is available (falls back to single-branch).
"""
cls.set_use_nodes(material, True)
for n in material.node_tree.nodes[:]:
material.node_tree.nodes.remove(n)
cls._build_flat_branch_nodes(material)
ext_output = tool.Blender.copy_node_graph_additive(material, ext_material)
if not ext_output:
# No shader editor available: fall back to single-branch
tool.Blender.copy_node_graph(material, ext_material)
return False
ext_output.name = "BIM_Output_External"
ext_output.is_active_output = True
material["bim_dual_branch"] = True
return True
@classmethod
def update_external_branch(cls, material: bpy.types.Material, ext_material: bpy.types.Material) -> None:
"""Replace the external-branch nodes of an already dual-branch material."""
cls._remove_external_branch(material)
ext_output = tool.Blender.copy_node_graph_additive(material, ext_material)
if ext_output:
ext_output.name = "BIM_Output_External"
ext_output.is_active_output = True
fast = material.node_tree.nodes.get("BIM_Output_Flat")
if fast:
fast.is_active_output = False
@classmethod
def sync_flat_branch_shading(
cls, material: bpy.types.Material, surface_colour: tuple[float, float, float], transparency: float
) -> None:
"""Update the flat-branch Principled BSDF with new shading values.
Call this after creating or editing IfcSurfaceStyleShading so the flat branch
stays in sync without requiring a full setup_dual_branch rebuild.
"""
if not material.node_tree:
return
fast_output = material.node_tree.nodes.get("BIM_Output_Flat")
if not fast_output:
return
for link in fast_output.inputs["Surface"].links:
if link.from_node.type == "BSDF_PRINCIPLED":
bsdf = link.from_node
r, g, b = surface_colour
bsdf.inputs["Base Color"].default_value = (r, g, b, 1.0)
bsdf.inputs["Alpha"].default_value = 1.0 - transparency
break
@classmethod
def switch_shading(cls, blender_material: bpy.types.Material, style_type: StyleType) -> None:
if style_type == "External":
ext, fast = cls.get_branch_outputs(blender_material)
if ext and fast:
ext.is_active_output = True
fast.is_active_output = False
blender_material.update_tag()
return
try:
bpy.ops.bim.activate_external_style(material_name=blender_material.name)
except RuntimeError as error:
@@ -994,41 +684,21 @@ class Style(bonsai.core.tool.Style):
return
raise error
elif style_type == "Shading":
ext, fast = cls.get_branch_outputs(blender_material)
if ext and fast:
fast.is_active_output = True
ext.is_active_output = False
blender_material.update_tag()
return
style_elements = tool.Style.get_style_elements(blender_material)
rendering_style = None
texture_style = None
shading_only_style = None
for surface_style in style_elements.values():
if surface_style.is_a() == "IfcSurfaceStyleShading":
shading_only_style = surface_style
tool.Loader.create_surface_style_shading(blender_material, surface_style)
elif surface_style.is_a("IfcSurfaceStyleRendering"):
rendering_style = surface_style
shading_only_style = None # rendering overrides shading-only path
tool.Loader.create_surface_style_rendering(blender_material, surface_style)
elif surface_style.is_a("IfcSurfaceStyleWithTextures"):
texture_style = surface_style
if rendering_style and texture_style:
tool.Loader.create_surface_style_with_textures(blender_material, rendering_style, texture_style)
elif shading_only_style and not rendering_style:
# create a minimal Principled BSDF so Material Preview/Rendered shows the colour instead of white.
tool.Style.set_use_nodes(blender_material, True)
tool.Loader.restart_material_node_tree(blender_material)
bsdf = tool.Blender.get_material_node(blender_material, "BSDF_PRINCIPLED")
if bsdf:
r, g, b, a = blender_material.diffuse_color
bsdf.inputs["Base Color"].default_value = (r, g, b, 1)
bsdf.inputs["Alpha"].default_value = a
if a < 1.0:
blender_material.blend_method = "BLEND"
else:
assert False, f"Invalid style type found: {style_type}"
@@ -1074,36 +744,3 @@ class Style(bonsai.core.tool.Style):
elements = ifcopenshell.util.element.get_elements_by_style(tool.Ifc.get(), style)
objects = [tool.Ifc.get_object(e) for e in elements]
tool.Geometry.reload_representation(objects)
_last_shading_type: str | None = None
@classmethod
def restore_material_style_types(cls, shading_type: str) -> None:
"""Set each IFC material's active_style_type to the richest available for the given viewport mode.
In SOLID mode all materials use "Shading".
In MATERIAL_PREVIEW / RENDERED, materials with an external .blend style use "External"
unless prefer_ifc_shading is set on that material.
"""
if cls._last_shading_type == shading_type:
return
cls._last_shading_type = shading_type
for material in bpy.data.materials:
if not tool.Blender.get_ifc_definition_id(material):
continue
props = cls.get_material_style_props(material)
style_elements = cls.get_style_elements(material)
if shading_type == "SOLID":
props.active_style_type = "Shading"
shading = style_elements.get("IfcSurfaceStyleRendering") or style_elements.get("IfcSurfaceStyleShading")
if shading:
d = tool.Loader.surface_style_to_dict(shading)
alpha = 1.0 - (d.get("Transparency") or 0.0)
material.diffuse_color = d["SurfaceColour"] + (alpha,)
else: # MATERIAL_PREVIEW or RENDERED
if cls.has_blender_external_style(style_elements) and not props.prefer_ifc_shading:
props.active_style_type = "External"
else:
props.active_style_type = "Shading"
material.update_tag()
+11 -26
View File
@@ -15,7 +15,6 @@ Example usage:
"""
import argparse
import shutil
import subprocess
import sys
@@ -28,17 +27,6 @@ if sys.platform not in available_platforms:
print(f"Currently only available on {', '.join(available_platforms)}. Not available on {sys.platform}.")
exit(1)
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--skip-binaries",
action="store_true",
help=(
"Skip copying compiled dependencies (e.g. ifcopenshell_wrapper) to the repo. "
"Useful if you already have the latest binaries in the repo and don't want them to be overridden."
),
)
args = parser.parse_args()
# ---------------------------
# SETTINGS.
# ---------------------------
@@ -137,20 +125,17 @@ def main() -> None:
path.unlink()
subprocess.check_call(("git", "checkout", "--", symlinks_glob), cwd=REPO_PATH)
if args.skip_binaries:
print("Skipping copying compiled dependencies to the repo...")
else:
print("Copying compiled dependencies to the repo...")
dest = REPO_PATH / "src" / "ifcopenshell-python" / "ifcopenshell"
for path in PACKAGE_PATH.glob("ifcopenshell/*_wrapper*"):
if path.suffix.lower() == ".pyi":
continue
dest_ = dest / path.name
print(f"Copying {path} -> {dest_}")
try:
shutil.copy(path, dest_)
except shutil.SameFileError:
pass
print("Copying compiled dependencies to the repo...")
dest = REPO_PATH / "src" / "ifcopenshell-python" / "ifcopenshell"
for path in PACKAGE_PATH.glob("ifcopenshell/*_wrapper*"):
if path.suffix.lower() == ".pyi":
continue
dest_ = dest / path.name
print(f"Copying {path} -> {dest_}")
try:
shutil.copy(path, dest_)
except shutil.SameFileError:
pass
print("Symlinking extension to the git repo...")
# fmt: off
+6 -12
View File
@@ -12,19 +12,16 @@ Scenario: Ensure added booleans are marked as manual
And I click "OK"
And the object "IfcFurniture/Unnamed" exists
And I toggle edit mode
And the variable "extrusion" is "{ifc}.by_type('IfcExtrudedAreaSolid')[0].id()"
And the object "Item/IfcExtrudedAreaSolid/{extrusion}" exists
And the object "Item/IfcExtrudedAreaSolid/73" exists
And I open the "Add Item" menu
When I click "Half Space Solid"
And the variable "half_space" is "{ifc}.by_type('IfcHalfSpaceSolid')[0].id()"
And the variable "boolean" is "{ifc}.by_type('IfcBooleanResult')[0].id()"
And the object "Item/IfcHalfSpaceSolid/{half_space}" exists
And the object "Item/IfcHalfSpaceSolid/90" exists
And I deselect all objects
And I toggle edit mode
And I select the object "IfcFurniture/Unnamed"
And I look at the "Property Sets" panel
Then I see "BBIM_Boolean"
And I see "[{boolean}]"
And I see "[91]"
Scenario: Ensure removed booleans are unmarked as manual
Given an empty IFC project
@@ -36,20 +33,17 @@ Scenario: Ensure removed booleans are unmarked as manual
And I click "OK"
And the object "IfcFurniture/Unnamed" exists
And I toggle edit mode
And the variable "extrusion" is "{ifc}.by_type('IfcExtrudedAreaSolid')[0].id()"
And the object "Item/IfcExtrudedAreaSolid/{extrusion}" exists
And the object "Item/IfcExtrudedAreaSolid/73" exists
And I open the "Add Item" menu
And I click "Half Space Solid"
And the variable "half_space" is "{ifc}.by_type('IfcHalfSpaceSolid')[0].id()"
And the variable "boolean" is "{ifc}.by_type('IfcBooleanResult')[0].id()"
And I deselect all objects
And I toggle edit mode
And I select the object "IfcFurniture/Unnamed"
And I toggle edit mode
And I select the object "Item/IfcHalfSpaceSolid/{half_space}"
And I select the object "Item/IfcHalfSpaceSolid/90"
When I delete the selected objects
And I toggle edit mode
And I select the object "IfcFurniture/Unnamed"
And I look at the "Property Sets" panel
Then I don't see "BBIM_Boolean"
And I don't see "[{boolean}]"
And I don't see "[91]"
@@ -32,91 +32,64 @@ from test.bim.bootstrap import NewIfc
pytestmark = pytest.mark.project
def _make_library_file(*, with_child: bool = False) -> ifcopenshell.file:
"""Build a spec-valid IFC4 library file: IfcProject + IfcProjectLibrary declared to it.
def _make_library_only_file(*, with_child: bool = False) -> ifcopenshell.file:
"""Build a minimal IFC4 file containing only an IfcProjectLibrary (no IfcProject).
Per the IFC Project Context concept template, every project data set (library
files included) shall contain exactly one IfcProject, and IfcProjectLibrary
instances are assigned to it via IfcRelDeclares. This matches how every library
file shipped in bonsai/bim/data/libraries is actually authored. ``with_child=True``
also nests a sub-library under the root via IfcRelNests.
Per IFC4+, a file must contain at least one IfcContext; IfcProjectLibrary is a
valid root on its own. ``with_child=True`` nests a sub-library under the root via
IfcRelNests, mirroring real authored library files.
"""
library_file = ifcopenshell.api.project.create_file(version="IFC4")
project = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProject", name="Demo Project")
root = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="RootLib")
ifcopenshell.api.project.assign_declaration(library_file, definitions=[root], relating_context=project)
if with_child:
child = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="ChildLib")
ifcopenshell.api.nest.assign_object(library_file, [child], root)
return library_file
class TestLibraryFile(NewIfc):
"""Project-library UI code operating on a spec-valid model (IfcProject root).
class TestLibraryOnlyFile(NewIfc):
def test_get_root_context_returns_project_library_when_no_project(self):
library_file = _make_library_only_file()
assert not library_file.by_type("IfcProject")
A file containing only IfcProjectLibrary and no IfcProject is not valid IFC and
is not supported; see test_parent_libraries_enum_raises_for_a_file_without_a_project.
"""
root = tool.Project.get_root_context(library_file)
def test_parent_libraries_enum_raises_for_a_file_without_a_project(self):
library_file = ifcopenshell.api.project.create_file(version="IFC4")
ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="RootLib")
IfcStore.library_file = library_file
try:
with pytest.raises(IndexError):
ProjectLibraryData.parent_libraries_enum()
finally:
IfcStore.library_file = None
assert root.is_a("IfcProjectLibrary")
assert root.Name == "RootLib"
def test_get_parent_library_returns_project_for_declared_root_library(self):
library_file = _make_library_file()
project = library_file.by_type("IfcProject")[0]
def test_get_parent_library_returns_none_for_root_library(self):
library_file = _make_library_only_file()
root = library_file.by_type("IfcProjectLibrary")[0]
assert tool.Project.get_parent_library(root) == project
assert tool.Project.get_parent_library(root) is None
def test_get_parent_library_returns_the_library_for_a_nested_sub_library(self):
library_file = _make_library_file(with_child=True)
root = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "RootLib")
child = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "ChildLib")
assert tool.Project.get_parent_library(child) == root
def test_get_parent_library_returns_none_for_an_orphaned_library(self):
library_file = ifcopenshell.api.project.create_file(version="IFC4")
orphan = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="Orphan")
assert tool.Project.get_parent_library(orphan) is None
def test_get_project_hierarchy_roots_libraries_under_the_project(self):
library_file = _make_library_file(with_child=True)
project = library_file.by_type("IfcProject")[0]
def test_get_project_hierarchy_skips_root_library(self):
library_file = _make_library_only_file(with_child=True)
root = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "RootLib")
child = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "ChildLib")
hierarchy = tool.Project.get_project_hierarchy(library_file)
assert root in hierarchy[project]
assert root in hierarchy
assert child in hierarchy[root]
def test_project_library_data_loads_with_unique_enum_keys(self):
IfcStore.library_file = _make_library_file(with_child=True)
def test_project_library_data_loads_without_crash(self):
IfcStore.library_file = _make_library_only_file()
try:
ProjectLibraryData.is_loaded = False
ProjectLibraryData.load()
assert ProjectLibraryData.is_loaded
enum = ProjectLibraryData.data["parent_libraries_enum"]
keys = [entry[0] for entry in enum]
assert len(keys) == len(set(keys))
assert enum[0][1].startswith("IfcProject ")
assert len(enum) == 1
assert enum[0][1].startswith("IfcProjectLibrary ")
finally:
IfcStore.library_file = None
ProjectLibraryData.is_loaded = False
def test_refresh_library_succeeds(self):
def test_refresh_library_succeeds_on_library_only_file(self):
import bpy
IfcStore.library_file = _make_library_file(with_child=True)
IfcStore.library_file = _make_library_only_file(with_child=True)
try:
result = bpy.ops.bim.refresh_library()
assert result == {"FINISHED"}
@@ -124,65 +97,13 @@ class TestLibraryFile(NewIfc):
IfcStore.library_file = None
ProjectLibraryData.is_loaded = False
def test_edit_project_library_moves_a_project_declared_library_under_another_library(self):
def test_add_project_library_nests_under_root_when_no_project(self):
import bpy
library_file = _make_library_file()
project = library_file.by_type("IfcProject")[0]
root = library_file.by_type("IfcProjectLibrary")[0]
target = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="TargetLib")
ifcopenshell.api.project.assign_declaration(library_file, definitions=[target], relating_context=project)
IfcStore.library_file = library_file
try:
props = tool.Project.get_project_props()
props.selected_project_library = str(root.id())
props.is_editing_project_library = True
props.parent_library = str(target.id())
result = bpy.ops.bim.edit_project_library()
assert result == {"FINISHED"}
assert tool.Project.get_parent_library(root) == target
assert root.Nests and root.Nests[0].RelatingObject == target
assert not root.HasContext
finally:
if props.is_editing_project_library:
props.is_editing_project_library = False
IfcStore.library_file = None
ProjectLibraryData.is_loaded = False
def test_edit_project_library_moves_a_nested_library_back_under_the_project(self):
import bpy
library_file = _make_library_file(with_child=True)
project = library_file.by_type("IfcProject")[0]
child = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "ChildLib")
IfcStore.library_file = library_file
try:
props = tool.Project.get_project_props()
props.selected_project_library = str(child.id())
props.is_editing_project_library = True
props.parent_library = str(project.id())
result = bpy.ops.bim.edit_project_library()
assert result == {"FINISHED"}
assert tool.Project.get_parent_library(child) == project
assert child.HasContext and child.HasContext[0].RelatingContext == project
assert not child.Nests
finally:
if props.is_editing_project_library:
props.is_editing_project_library = False
IfcStore.library_file = None
ProjectLibraryData.is_loaded = False
def test_add_project_library_declares_new_library_under_the_project_root(self):
import bpy
IfcStore.library_file = _make_library_file()
IfcStore.library_file = _make_library_only_file()
library_file = IfcStore.library_file
try:
project = library_file.by_type("IfcProject")[0]
root = library_file.by_type("IfcProjectLibrary")[0]
before = set(library_file.by_type("IfcProjectLibrary"))
result = bpy.ops.bim.add_project_library()
@@ -192,9 +113,9 @@ class TestLibraryFile(NewIfc):
new_libraries = after - before
assert len(new_libraries) == 1
new_library = next(iter(new_libraries))
assert new_library.HasContext
assert new_library.HasContext[0].RelatingContext == project
assert not new_library.Nests
assert new_library.Nests
assert new_library.Nests[0].RelatingObject == root
assert not new_library.HasContext
finally:
IfcStore.library_file = None
ProjectLibraryData.is_loaded = False
-4
View File
@@ -21,10 +21,6 @@ from typing import Any, Optional, Union
class bSDDClientStub:
def __init__(self):
# Mirrors bsdd.Client so tool.Bsdd.identifier_url() works against the stub.
self.baseurl = "https://api.bsdd.buildingsmart.org/api/"
def get_dictionary(self, dictionary_uri=None, include_test_dictionaries=False):
dicts = {
"dictionaries": [
+2 -26
View File
@@ -187,14 +187,7 @@ class PanelSpy:
after = ""
if self.spied_labels:
after = self.spied_labels[-1]
spied_operator = {
"operator": operator,
"icon": icon,
"text": text,
"kwargs": {},
"after": after,
"bl_idname": bl_idname,
}
spied_operator = {"operator": operator, "icon": icon, "text": text, "kwargs": {}, "after": after}
self.spied_operators.append(spied_operator)
return OperatorSpy(spied_operator)
elif self.spied_attr == "panel":
@@ -217,14 +210,6 @@ class OperatorSpy:
else:
self.spied_data["kwargs"][name] = value
@property
def bl_rna(self) -> Any:
# Mirror the real `UILayout.operator()` return value (an OperatorProperties
# instance), which exposes `.bl_rna` so panel code such as
# `"module" in op.bl_rna.properties` (bonsai/bim/helper.py) also works when
# drawing is spied on during BDD tests.
return getattr(bpy.types, self.spied_data["bl_idname"]).bl_rna
class TemplateListSpy(PanelSpy):
items: bpy.types.bpy_prop_collection_idprop[bpy.types.PropertyGroup]
@@ -468,7 +453,6 @@ def i_trigger_operator(operator):
@then(parsers.parse('I see "{text}"'))
def i_see_text(text):
assert panel_spy
text = replace_variables(text)
panel_spy.refresh_spy()
assert [l for l in panel_spy.spied_labels if text in l], f"Text {text} not found in {panel_spy.spied_labels}"
@@ -603,7 +587,6 @@ def i_select_the_row_where_i_see_text_in_the_nth_list(text, nth):
@then(parsers.parse('I don\'t see "{text}"'))
def i_dont_see_text(text):
assert panel_spy
text = replace_variables(text)
panel_spy.refresh_spy()
assert not [l for l in panel_spy.spied_labels if text in l], f"Text {text} found in {panel_spy.spied_labels}"
@@ -790,11 +773,7 @@ def i_create_default_mep_types():
with bpy.context.temp_override(active_object=bpy.data.objects["IfcActuatorType/ACTUATOR"]):
bpy.ops.bim.add_port()
# port at cube's left side
# Newly created ports are never given an explicit IFC `.Name` (see
# `core/system.py:create_port_at_cursor` / `tool/system.py`), so
# `tool.Loader.get_name()` falls back to the standard "Unnamed" convention
# used throughout Bonsai for freshly-created, not-yet-named elements.
bpy.data.objects["IfcDistributionPort/Unnamed"].location = (-0.5, 0, 0)
bpy.data.objects["IfcDistributionPort/Port"].location = (-0.5, 0, 0)
bpy.ops.bim.hide_ports()
@@ -1094,7 +1073,6 @@ def then_the_object_name_is_placed_in_the_collection_collection(name: str, colle
@given(parsers.parse('additionally the object "{name}" is selected'))
@when(parsers.parse('additionally the object "{name}" is selected'))
def additionally_the_object_name_is_selected(name):
name = replace_variables(name)
obj = bpy.context.scene.objects.get(name)
if not obj:
total = len(bpy.context.scene.objects)
@@ -1175,7 +1153,6 @@ def nothing_happens():
@when(parsers.parse('the object "{name}" exists'))
@then(parsers.parse('the object "{name}" exists'))
def the_object_name_exists(name: str) -> bpy.types.Object:
name = replace_variables(name)
# Some objects from linked collections may share the same name. This disambiguates them.
if name.startswith("Col:"):
_, collection_name, name = name.split(":")
@@ -1190,7 +1167,6 @@ def the_object_name_exists(name: str) -> bpy.types.Object:
@then(parsers.parse('the object "{name}" does not exist'))
def the_object_name_does_not_exist(name) -> None:
name = replace_variables(name)
obj = bpy.data.objects.get(name)
assert obj is None, f'The object "{name}" exists'

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