diff --git a/.github/workflows/build_osx.yml b/.github/workflows/build_osx.yml
index 0f6bb702e9..4c6cb9312c 100644
--- a/.github/workflows/build_osx.yml
+++ b/.github/workflows/build_osx.yml
@@ -53,7 +53,7 @@ jobs:
python ../nix/cache_dependencies.py unpack
- name: ccache
- uses: hendrikmuhs/ccache-action@v1.2.20
+ uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: mac-${{ matrix.arch }}
diff --git a/.github/workflows/build_pyodide.yml b/.github/workflows/build_pyodide.yml
index 7da3bb408c..a5e80a32df 100644
--- a/.github/workflows/build_pyodide.yml
+++ b/.github/workflows/build_pyodide.yml
@@ -29,7 +29,7 @@ jobs:
python ../IfcOpenShell/nix/cache_dependencies.py unpack
- name: ccache
- uses: hendrikmuhs/ccache-action@v1.2.20
+ uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: ubuntu-22.04-${{ runner.arch }}
diff --git a/.github/workflows/build_rocky.yml b/.github/workflows/build_rocky.yml
index 710b918564..9ac489378b 100644
--- a/.github/workflows/build_rocky.yml
+++ b/.github/workflows/build_rocky.yml
@@ -48,7 +48,7 @@ jobs:
python3 ../nix/cache_dependencies.py unpack
- name: ccache
- uses: hendrikmuhs/ccache-action@v1.2.20
+ uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
diff --git a/.github/workflows/build_rocky_arm.yml b/.github/workflows/build_rocky_arm.yml
index b54e62ccef..208e1da9da 100644
--- a/.github/workflows/build_rocky_arm.yml
+++ b/.github/workflows/build_rocky_arm.yml
@@ -48,7 +48,7 @@ jobs:
python3 ../nix/cache_dependencies.py unpack
- name: ccache
- uses: hendrikmuhs/ccache-action@v1.2.20
+ uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
diff --git a/.github/workflows/build_win.yml b/.github/workflows/build_win.yml
index 783084c1ae..7880296a2a 100644
--- a/.github/workflows/build_win.yml
+++ b/.github/workflows/build_win.yml
@@ -5,11 +5,26 @@ on:
jobs:
build_ifcopenshell:
- runs-on: windows-2022
strategy:
fail-fast: false
matrix:
- arch: ['x64']
+ include:
+ - arch: x64
+ runs_on: windows-2022
+ deps_dir: _deps-vs2022-x64-installed
+ vcvars: '"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvars64.bat"'
+ build_branch: windows-x64
+ zip_suffix: win64
+
+ - arch: ARM64
+ runs_on: windows-11-arm
+ deps_dir: _deps-vs2022-ARM64-installed
+ vcvars: '"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvarsarm64.bat"'
+ build_branch: windows-arm64
+ zip_suffix: win-arm64
+
+ runs-on: ${{ matrix.runs_on }}
+
steps:
- name: Checkout Repository
uses: actions/checkout@v6
@@ -20,8 +35,8 @@ jobs:
uses: actions/checkout@v6
with:
repository: IfcOpenShell/build-outputs
- path: _deps-vs2022-x64-installed
- ref: windows-${{ matrix.arch }}
+ path: ${{ matrix.deps_dir }}
+ ref: ${{ matrix.build_branch }}
lfs: true
token: ${{ secrets.BUILD_REPO_TOKEN }}
@@ -31,13 +46,13 @@ jobs:
- name: Unpack Dependencies
run: |
- cd _deps-vs2022-x64-installed
+ cd ${{ matrix.deps_dir }}
Get-ChildItem -Path . -Filter 'cache-*.zip' | ForEach-Object {
7z x $_.FullName
}
- name: ccache
- uses: hendrikmuhs/ccache-action@v1.2.20
+ uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: win-${{ matrix.arch }}
# Windows ccache needs ~1GB
@@ -46,14 +61,16 @@ jobs:
- name: Run Build Script And Pack .zip Archives
shell: cmd
+ env:
+ TARGET_ARCH: ${{ matrix.arch }} # lets the Python script know which arch to target (optional override)
run: |
- call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvars64.bat"
+ call ${{ matrix.vcvars }}
cd win
python build-all-win.py
- name: Pack Dependencies
run: |
- cd _deps-vs2022-x64-installed
+ cd ${{ matrix.deps_dir }}
Get-ChildItem -Path . -Directory | ForEach-Object {
$cacheFile = "cache-$($_.Name).zip"
echo $cacheFile
@@ -64,12 +81,13 @@ jobs:
- name: Commit and Push Changes to Build Repository
run: |
- cd _deps-vs2022-x64-installed
+ cd ${{ matrix.deps_dir }}
git config user.name "IfcOpenBot"
git config user.email "ifcopenbot@ifcopenshell.org"
+ git checkout -B ${{ matrix.build_branch }}
git add *.zip
git commit -m "Update build artifacts [skip ci]" || echo "No changes to commit"
- git push || echo "Push failed"
+ git push --set-upstream origin ${{ matrix.build_branch }} || echo "Push failed"
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v6
diff --git a/.github/workflows/ci-black-formatting.yaml b/.github/workflows/ci-black-formatting.yaml
index 90a0b95431..f081f02945 100644
--- a/.github/workflows/ci-black-formatting.yaml
+++ b/.github/workflows/ci-black-formatting.yaml
@@ -7,6 +7,9 @@ on:
jobs:
lint-formatting:
runs-on: ubuntu-latest
+ env:
+ MIN_IOS_PY_VERSION: "3.10"
+ MIN_BLENDER_PY_VERSION: "3.11"
steps:
- name: Action - checkout repository
uses: actions/checkout@v6
@@ -14,12 +17,12 @@ jobs:
- name: Action - install python
uses: actions/setup-python@v6
with:
- python-version: "3.10"
+ python-version: ${{ env.MIN_IOS_PY_VERSION }}
- name: Action - install python
uses: actions/setup-python@v6
with:
- python-version: "3.11"
+ python-version: ${{ env.MIN_BLENDER_PY_VERSION }}
- name: Install dependencies
run: |
@@ -33,8 +36,10 @@ jobs:
id: syntax-errors
run: |
ERROR=0
- python3.10 -W error -m compileall -q src/ifcopenshell-python || ERROR=1
- python3.11 -W error -m compileall -q src/bonsai || ERROR=1
+ # Using 2 Python versions - one minimum required for IfcOpenShell
+ # and other that's used by Blender currently.
+ python${{ env.MIN_IOS_PY_VERSION }} -W error -m compileall -q src/ifcopenshell-python || ERROR=1
+ python${{ env.MIN_BLENDER_PY_VERSION }} -W error -m compileall -q src/bonsai || ERROR=1
exit $ERROR
continue-on-error: true
diff --git a/.github/workflows/ci-bonsai-daily.yml b/.github/workflows/ci-bonsai-daily.yml
index ddfa64a47a..68b6ac2dbb 100644
--- a/.github/workflows/ci-bonsai-daily.yml
+++ b/.github/workflows/ci-bonsai-daily.yml
@@ -109,7 +109,7 @@ jobs:
# Ensure Bonsai and ifcsverchok enable/disable works before uploading to extensions repo.
# Download Blender.
- wget -q -O blender.tar.xz https://download.blender.org/release/Blender5.0/blender-5.0.1-linux-x64.tar.xz
+ wget -q -O blender.tar.xz https://download.blender.org/release/Blender5.0/blender-5.1.0-linux-x64.tar.xz
tar -xf blender.tar.xz
# Setup Blender.
diff --git a/.github/workflows/ci-ifcedit-pypi.yaml b/.github/workflows/ci-ifcedit-pypi.yaml
new file mode 100644
index 0000000000..d961c72f56
--- /dev/null
+++ b/.github/workflows/ci-ifcedit-pypi.yaml
@@ -0,0 +1,35 @@
+name: ci-ifcedit-pypi
+
+on:
+ workflow_dispatch:
+
+jobs:
+ activate:
+ runs-on: ubuntu-latest
+ if: |
+ github.repository == 'IfcOpenShell/IfcOpenShell'
+ steps:
+ - name: Set env
+ run: echo ok go
+
+ build:
+ needs: activate
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-python@v6
+ with:
+ python-version: '3.11'
+ - name: Compile
+ run: |
+ pip install build
+ cd src/ifcedit &&
+ make dist IS_STABLE=TRUE
+ - name: Publish a Python distribution to PyPI
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ user: __token__
+ password: ${{ secrets.PYPI_API_TOKEN }}
+ packages_dir: src/ifcedit/dist
diff --git a/.github/workflows/ci-ifcmcp-pypi.yaml b/.github/workflows/ci-ifcmcp-pypi.yaml
new file mode 100644
index 0000000000..388c10217a
--- /dev/null
+++ b/.github/workflows/ci-ifcmcp-pypi.yaml
@@ -0,0 +1,36 @@
+name: ci-ifcmcp-pypi
+
+on:
+ workflow_dispatch:
+
+jobs:
+ activate:
+ runs-on: ubuntu-latest
+ if: |
+ github.repository == 'IfcOpenShell/IfcOpenShell'
+ steps:
+ - name: Set env
+ run: echo ok go
+
+ build:
+ needs: activate
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-python@v6
+ with:
+ python-version: '3.11'
+ - name: Compile
+ run: |
+ pip install build
+ cd src/ifcmcp &&
+ make dist IS_STABLE=TRUE
+ - name: Publish a Python distribution to PyPI
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ user: __token__
+ password: ${{ secrets.PYPI_API_TOKEN }}
+ packages_dir: src/ifcmcp/dist
+ verbose: true
diff --git a/.github/workflows/ci-ifcopenshell-conda-cleaner.yml b/.github/workflows/ci-ifcopenshell-conda-cleaner.yml
index 5c1b80bcc6..c34cf50e0f 100644
--- a/.github/workflows/ci-ifcopenshell-conda-cleaner.yml
+++ b/.github/workflows/ci-ifcopenshell-conda-cleaner.yml
@@ -24,7 +24,7 @@ jobs:
if: |
github.repository == 'IfcOpenShell/IfcOpenShell'
steps:
- - uses: mamba-org/setup-micromamba@v2 # https://github.com/mamba-org/setup-micromamba
+ - uses: mamba-org/setup-micromamba@v3 # https://github.com/mamba-org/setup-micromamba
with:
environment-name: test-env
create-args: >-
diff --git a/.github/workflows/ci-ifcopenshell-conda-daily.yml b/.github/workflows/ci-ifcopenshell-conda-daily.yml
index b648538ed7..3d18316963 100644
--- a/.github/workflows/ci-ifcopenshell-conda-daily.yml
+++ b/.github/workflows/ci-ifcopenshell-conda-daily.yml
@@ -84,7 +84,7 @@ jobs:
run: |
curl -L https://github.com/phracker/MacOSX-SDKs/releases/download/11.3/MacOSX10.13.sdk.tar.xz | tar -xvJf - -C /Users/runner/work/
- - uses: mamba-org/setup-micromamba@v2 # https://github.com/mamba-org/setup-micromamba
+ - uses: mamba-org/setup-micromamba@v3 # https://github.com/mamba-org/setup-micromamba
with:
environment-name: test-env
create-args: >-
diff --git a/.github/workflows/ci-ifcopenshell-docker.yml b/.github/workflows/ci-ifcopenshell-docker.yml
index e10be5a18b..df672139b8 100644
--- a/.github/workflows/ci-ifcopenshell-docker.yml
+++ b/.github/workflows/ci-ifcopenshell-docker.yml
@@ -35,7 +35,7 @@ jobs:
-
name: ccache
- uses: hendrikmuhs/ccache-action@v1.2.20
+ uses: hendrikmuhs/ccache-action@v1.2.22
-
name: Build ifcopenshell
@@ -91,26 +91,26 @@ jobs:
lfs: true
- name: Download
- uses: actions/download-artifact@v8.0.0
+ uses: actions/download-artifact@v8.0.1
with:
# Artifact name
name: ifcos-artifacts
path: artifacts/
-
name: Set up QEMU
- uses: docker/setup-qemu-action@v3
+ uses: docker/setup-qemu-action@v4
-
name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@v4
-
name: Login to Dockerhub
- uses: docker/login-action@v3
+ uses: docker/login-action@v4
with:
username: aecgeeks
password: ${{ secrets.DOCKER_HUB_TOKEN }}
-
name: Build container image
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@v7
with:
context: artifacts
repository: aecgeeks/ifcopenshell
diff --git a/.github/workflows/ci-ifcopenshell-python-pypi.yml b/.github/workflows/ci-ifcopenshell-python-pypi.yml
index 526aa09b64..7988a0bc6a 100644
--- a/.github/workflows/ci-ifcopenshell-python-pypi.yml
+++ b/.github/workflows/ci-ifcopenshell-python-pypi.yml
@@ -24,7 +24,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- pyver: [py39, py310, py311, py312, py313, py314]
+ pyver: [py310, py311, py312, py313, py314]
config:
- {
name: "Windows 64bit",
diff --git a/.github/workflows/ci-ifcopenshell-python.yml b/.github/workflows/ci-ifcopenshell-python.yml
index 5d34335ada..fe99f4857b 100644
--- a/.github/workflows/ci-ifcopenshell-python.yml
+++ b/.github/workflows/ci-ifcopenshell-python.yml
@@ -19,7 +19,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- pyver: [py39, py310, py311, py312, py313, py314]
+ pyver: [py310, py311, py312, py313, py314]
config:
- {
name: "Windows 64bit",
diff --git a/.github/workflows/ci-ifcquery-pypi.yaml b/.github/workflows/ci-ifcquery-pypi.yaml
new file mode 100644
index 0000000000..9dc39a9304
--- /dev/null
+++ b/.github/workflows/ci-ifcquery-pypi.yaml
@@ -0,0 +1,35 @@
+name: ci-ifcquery-pypi
+
+on:
+ workflow_dispatch:
+
+jobs:
+ activate:
+ runs-on: ubuntu-latest
+ if: |
+ github.repository == 'IfcOpenShell/IfcOpenShell'
+ steps:
+ - name: Set env
+ run: echo ok go
+
+ build:
+ needs: activate
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-python@v6
+ with:
+ python-version: '3.11'
+ - name: Compile
+ run: |
+ pip install build
+ cd src/ifcquery &&
+ make dist IS_STABLE=TRUE
+ - name: Publish a Python distribution to PyPI
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ user: __token__
+ password: ${{ secrets.PYPI_API_TOKEN }}
+ packages_dir: src/ifcquery/dist
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 896a179758..a49c92fcd7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -79,7 +79,7 @@ jobs:
libhdf5-dev libcgal-dev libeigen3-dev
- name: ccache
- uses: hendrikmuhs/ccache-action@v1.2.20
+ uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: ubuntu-22.04-${{ runner.arch }}
@@ -181,6 +181,7 @@ jobs:
"-DSCHEMA_VERSIONS=2x3;4;4x3_add2" \
-DGLTF_SUPPORT=On \
-DWITH_ROCKSDB=On \
+ -DBUILD_EXAMPLES=ON \
../cmake
sudo make -j $(nproc)
sudo make install
@@ -253,6 +254,7 @@ jobs:
cd ../ifcpatch && make test || ERROR=1
pip install -e ../ifctester --no-deps
cd ../ifctester && make test || ERROR=1
+ make build-ids-docs || ERROR=1
# Run mathutils related tests at the end to ensure no other code is relying on mathutils.
cd ../ifcopenshell-python
pip install mathutils
diff --git a/.github/workflows/publish-aichat-app.yaml b/.github/workflows/publish-aichat-app.yaml
new file mode 100644
index 0000000000..776781428e
--- /dev/null
+++ b/.github/workflows/publish-aichat-app.yaml
@@ -0,0 +1,65 @@
+name: Deploy AI chat App to static page repo
+
+permissions:
+ id-token: write
+ pages: write
+
+on:
+ push:
+ paths:
+ - 'src/ifcchat/**'
+ - '.github/workflows/publish-aichat-app.yaml'
+ branches:
+ - v0.8.0
+ workflow_dispatch:
+
+jobs:
+ activate:
+ runs-on: ubuntu-latest
+ if: |
+ github.repository == 'IfcOpenShell/IfcOpenShell'
+ steps:
+ - name: Set env
+ run: echo ok go
+
+ build:
+ needs: activate
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout (recursive)
+ uses: actions/checkout@v6
+ with:
+ submodules: recursive
+ fetch-depth: 0
+ - name: Checkout intermediate Pages repo
+ uses: actions/checkout@v4
+ with:
+ repository: IfcOpenShell/aichat_ifcopenshell_org_static_html
+ ref: gh-pages
+ path: output
+ token: ${{ secrets.WEBSITE_PUBLISH }}
+ - name: Sync demo app into target subfolder
+ run: |
+ rsync -av --delete --exclude='.git/' src/ifcchat/ output/
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.x"
+ - name: Download wheels
+ working-directory: output/
+ run: |
+ pip download ifcquery==0.8.5 ifcopenshell-mcp==0.8.5 ifcedit==0.8.5 lark==1.3.1 isodate==0.7.2 --no-deps -d ./dist
+ - name: Commit and push if changed
+ working-directory: output
+ run: |
+ git config --global user.name 'IfcOpenBot'
+ git config --global user.email 'IfcOpenBot@users.noreply.github.com'
+
+ git add .
+ if git diff --cached --quiet; then
+ echo "No changes to commit"
+ exit 0
+ fi
+
+ git commit -m "$(git log --oneline -1)"
+ git push origin gh-pages
diff --git a/.github/workflows/publish-pyodide-demo-app.yml b/.github/workflows/publish-pyodide-demo-app.yml
index 4aebe4266e..9f4c3ddbf8 100644
--- a/.github/workflows/publish-pyodide-demo-app.yml
+++ b/.github/workflows/publish-pyodide-demo-app.yml
@@ -1,4 +1,4 @@
-name: Deploy Pyodide Demo App to GitHub Pages
+name: Deploy Pyodide Demo App to static page repo
permissions:
id-token: write
@@ -11,6 +11,7 @@ on:
- '.github/workflows/publish-pyodide-demo-app.yml'
branches:
- v0.8.0
+ workflow_dispatch:
jobs:
activate:
@@ -30,21 +31,27 @@ jobs:
with:
submodules: recursive
fetch-depth: 0
- - name: Setup Pages
- uses: actions/configure-pages@v5
- - name: Upload static files as artifact
- id: deployment
- uses: actions/upload-pages-artifact@v4
+ - name: Checkout intermediate Pages repo
+ uses: actions/checkout@v4
with:
- path: src/pyodide/demo-app/
+ repository: IfcOpenShell/wasm_ifcopenshell_org_static_html
+ ref: gh-pages
+ path: output
+ token: ${{ secrets.WEBSITE_PUBLISH }}
+ - name: Sync demo app into target subfolder
+ run: |
+ rsync -av --delete --exclude='.git/' src/pyodide/demo-app/ output/
+ - name: Commit and push if changed
+ working-directory: output
+ run: |
+ git config --global user.name 'IfcOpenBot'
+ git config --global user.email 'IfcOpenBot@users.noreply.github.com'
- deploy:
- environment:
- name: github-pages
- url: ${{ steps.deployment.outputs.page_url }}
- runs-on: ubuntu-latest
- needs: build
- steps:
- - name: Deploy to GitHub Pages
- id: deployment
- uses: actions/deploy-pages@v4
+ git add .
+ if git diff --cached --quiet; then
+ echo "No changes to commit"
+ exit 0
+ fi
+
+ git commit -m "$(git log --oneline -1)"
+ git push origin gh-pages
diff --git a/.gitignore b/.gitignore
index 8a7482ab8e..80fbb01a00 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,8 @@
/_installed-vs*-x*/
/build/
/src/examples/build/
+# ifctester docs output
+/src/ifctester/test/build/
# output directories
/cmake/out/
@@ -12,6 +14,7 @@
/src/ifcmax/out/
/src/ifcwrap/out/
/src/qtviewer/out/
+/src/ifctester/webapp/public/pyodide/
/win/BuildDepsCache*.txt
@@ -80,10 +83,14 @@ src/ifcopenshell-python/test/build
# bonsai i18n
src/bonsai/bonsai/translations.py
-# bonsai test temp files
+# bonsai external dependencies (cloned for just ty checks)
+src/bonsai/external_dependencies/
+
+# bonsai test temp/cache files
src/bonsai/test/files/temp
-src/bonsai/test/files/basic.ifc.cache.blend
-src/bonsai/test/files/basic.ifc.cache.sqlite
+src/bonsai/test/files/*.cache.blend
+src/bonsai/test/files/*.cache.json
+src/bonsai/test/files/*.cache.sqlite
# bonsai data
src/bonsai/bonsai/bim/data/build/
@@ -115,3 +122,9 @@ dev_environment.bat
src/ifcopenshell-python/ifcopenshell/express/*.exp
src/ifcopenshell-python/ifcopenshell/express/*.exp.cache.dat
+
+
+# temp files from AI coding tools
+*.claude
+*.py.tmp*
+*.json.tmp*
diff --git a/README.md b/README.md
index 6b77a04833..74c2ebe115 100644
--- a/README.md
+++ b/README.md
@@ -50,10 +50,13 @@ Contents
| [ifcconvert](https://docs.ifcopenshell.org/ifcconvert.html) | CLI app to convert IFC to many other formats | LGPL-3.0-or-later\* | [](https://docs.ifcopenshell.org/ifcconvert/installation.html) [](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcconvert&expanded=true)
| [ifccsv](https://docs.ifcopenshell.org/ifccsv.html) | Library and CLI app to export and import schedules from IFC | LGPL-3.0-or-later | [](https://pypi.org/project/ifccsv/) |
| [ifcdiff](https://docs.ifcopenshell.org/ifcdiff.html) | Compare changes between IFC models | LGPL-3.0-or-later | [](https://pypi.org/project/ifcdiff/) |
+| [ifcedit](https://docs.ifcopenshell.org/ifcedit.html) | CLI wrapper for ifcopenshell.api IFC model mutation functions | LGPL-3.0-or-later | [](https://pypi.org/project/ifcedit/) |
| [ifcfm](https://docs.ifcopenshell.org/ifcfm.html) | Extract IFC data for FM handover requirements | LGPL-3.0-or-later | [](https://pypi.org/project/ifcfm/) |
| [ifcmax](https://docs.ifcopenshell.org/ifcmax.html) | Historic extension for IFC support in 3DS Max | LGPL-3.0-or-later\* | [](https://docs.ifcopenshell.org/ifcmax.html)
+| [ifcmcp](https://docs.ifcopenshell.org/ifcmcp.html) | MCP server for querying and editing IFC building models | LGPL-3.0-or-later | [](https://pypi.org/project/ifcmcp/) |
| [ifcopenshell-python](https://docs.ifcopenshell.org/ifcopenshell-python.html) | Python library for IFC manipulation | LGPL-3.0-or-later\* | [](https://docs.ifcopenshell.org/ifcopenshell-python/installation.html) [](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcopenshell-python&expanded=true) [](https://pypi.org/project/ifcopenshell/) [](https://anaconda.org/conda-forge/ifcopenshell) [](https://anaconda.org/ifcopenshell/ifcopenshell) [](https://hub.docker.com/r/aecgeeks/ifcopenshell) [](https://aur.archlinux.org/packages/ifcopenshell) [](https://aur.archlinux.org/packages/ifcopenshell-git) [Pyodide WASM Wheels](https://github.com/IfcOpenShell/wasm-wheels#pyodide-test-wheels) |
| [ifcpatch](https://docs.ifcopenshell.org/ifcpatch.html) | Utility to run pre-packaged scripts to manipulate IFCs | LGPL-3.0-or-later | [](https://pypi.org/project/ifcpatch/) |
+| [ifcquery](https://docs.ifcopenshell.org/ifcquery.html) | CLI tool for querying and inspecting IFC building models | LGPL-3.0-or-later | [](https://pypi.org/project/ifcquery/) |
| [ifcsverchok](https://docs.ifcopenshell.org/ifcsverchok.html) | Blender Add-on for visual node programming with IFC | GPL-3.0-or-later | [](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcsverchok&expanded=true)
| [ifctester](https://docs.ifcopenshell.org/ifctester.html) | Library, CLI and webapp for IDS model auditing | LGPL-3.0-or-later | [](https://pypi.org/project/ifctester/) |
diff --git a/choco/bonsai/choco_release.py b/choco/bonsai/choco_release.py
index 681e6c58e5..ea53798c5d 100644
--- a/choco/bonsai/choco_release.py
+++ b/choco/bonsai/choco_release.py
@@ -13,6 +13,7 @@ import hashlib
import os
import pathlib
import re
+import subprocess
from typing import NoReturn
from urllib import request
@@ -20,7 +21,7 @@ from github import Github
def get_repo_tag_names() -> list[str]:
- git_return = os.popen("git tag -l").read()
+ git_return = subprocess.check_output("git tag -l", text=True)
tag_names = [tag_name for tag_name in git_return.split("\n") if tag_name]
print(f"{len(tag_names)} tag_names found in repo")
return tag_names
@@ -78,6 +79,10 @@ def get_release_zip(tag: str) -> tuple[str, str]:
raise Exception(f"Couldn't find the release matching '{python_version}' and '{TARGET_OS}' in tag '{tag}'.")
+def run(command: str) -> None:
+ subprocess.check_output(command)
+
+
start = datetime.datetime.now()
URL_CHOCO_PACKAGE = "https://community.chocolatey.org/packages/blender"
@@ -97,7 +102,7 @@ should_release = False
target_release_tag = ""
TARGET_OS = "windows-x64"
-git_status = os.popen("git status").read()
+git_status = subprocess.check_output("git status", text=True)
print(git_status)
for tag_name in get_repo_tag_names():
@@ -147,7 +152,7 @@ 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)
-os.popen(f"wget {url_blenderbim_py3x_win_zip} --no-verbose").read()
+subprocess.check_call(f"wget {url_blenderbim_py3x_win_zip} --no-verbose")
# sha256sum_blenderbim_py310_win_zip
sha256sum_blenderbim_py3x_win_zip = get_file_sha256_hash(release_zip_file_name)
@@ -201,13 +206,13 @@ print("[INFO] inserting dynamic chocolatey package parameters successful")
print("\n_____ build choco.exe with mono")
choco_version = "1.1.0"
-os.popen(f"wget https://github.com/chocolatey/choco/archive/refs/tags/{choco_version}.tar.gz --quiet").read()
-os.popen(f"tar -xzf {choco_version}.tar.gz").read()
+run(f"wget https://github.com/chocolatey/choco/archive/refs/tags/{choco_version}.tar.gz --quiet")
+run(f"tar -xzf {choco_version}.tar.gz")
print("choco tar unpack successful")
os.chdir("choco-1.1.0")
-os.popen("./build.sh").read()
+run("./build.sh")
-os.popen("cp -r build_output/chocolatey /opt/chocolatey").read()
+run("cp -r build_output/chocolatey /opt/chocolatey")
os.chdir(BLENDERBIM_DIR)
if pathlib.Path("/opt/chocolatey/choco.exe").exists():
@@ -215,11 +220,15 @@ if pathlib.Path("/opt/chocolatey/choco.exe").exists():
print("\n_____ build choco pack")
-os.popen("mono /opt/chocolatey/choco.exe pack --allow-unofficial").read()
-os.popen('mono /opt/chocolatey/choco.exe setapikey --key="{choco_token}" --source="https://push.chocolatey.org/" --allow-unofficial').read()
+run("mono /opt/chocolatey/choco.exe pack --allow-unofficial")
+run(
+ 'mono /opt/chocolatey/choco.exe setapikey --key="{choco_token}" --source="https://push.chocolatey.org/" --allow-unofficial'
+)
print("\n_____ build choco push")
-os.popen('mono /opt/chocolatey/choco.exe push --source="https://push.chocolatey.org/" --key="$CHOCO_TOKEN" --allow-unofficial --verbose').read()
+run(
+ 'mono /opt/chocolatey/choco.exe push --source="https://push.chocolatey.org/" --key="$CHOCO_TOKEN" --allow-unofficial --verbose'
+)
print(f"choco push of version: {target_release_tag} successful!")
print(f"it took: {datetime.datetime.now() - start}")
diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt
index bf48427774..917e66d192 100644
--- a/cmake/CMakeLists.txt
+++ b/cmake/CMakeLists.txt
@@ -80,7 +80,7 @@ option(BUILD_IFCGEOM "Build IfcGeom." ON)
option(BUILD_IFCPYTHON "Build IfcPython." ON)
option(BUILD_CONVERT "Build IfcConvert executable." ON)
option(BUILD_DOCUMENTATION "Build IfcOpenShell Documentation." OFF)
-option(BUILD_EXAMPLES "Build example applications." ON)
+option(BUILD_EXAMPLES "Build example applications." OFF)
option(BUILD_GEOMSERVER "Build IfcGeomServer executable (Open CASCADE is required)." ON)
option(BUILD_IFCMAX "Build IfcMax, a 3ds Max plug-in, Windows-only." OFF)
option(BUILD_QTVIEWER "Build IfcOpenShell Qt GUI Viewer" OFF) # QtViewer requires Qt6
diff --git a/nix/cache_dependencies.py b/nix/cache_dependencies.py
index 465d6002f1..3d115764b4 100644
--- a/nix/cache_dependencies.py
+++ b/nix/cache_dependencies.py
@@ -41,6 +41,9 @@ def pack_dependencies(install_dir: Path) -> None:
if not dependency_path.is_dir():
continue
dependency_name = dependency_path.name
+ # Skip ifcopenshell - it's a build output, not a dependency to reuse across builds.
+ if dependency_name == "ifcopenshell":
+ continue
tar_path = install_dir / f"{CACHE_PREFIX}{dependency_name}.tar.gz"
if tar_path.exists():
print(f"Skipping existing cache: '{tar_path}'")
diff --git a/pyproject.toml b/pyproject.toml
index bdc9bf1a64..330f6150d0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -2,10 +2,10 @@
name = "IfcOpenShell"
version = "0.0.0"
dependencies = [
- "black==26.1.0",
- "ruff==0.15.4",
+ "black==26.3.1",
+ "ruff==0.15.8",
"poethepoet",
- "gersemi==0.26.0",
+ "gersemi==0.26.1",
]
[tool.black]
@@ -28,6 +28,12 @@ extend-exclude = '''
reportInvalidTypeForm = false
disableBytesTypePromotions = true
reportUnnecessaryTypeIgnoreComment = true
+# Pylance doesn't respect gitignore, so we have to exclude files manually here
+# to avoid VS Code slowing down.
+# https://github.com/microsoft/pylance-release/issues/5169
+exclude = [
+ "_deps",
+]
# Define here general ruff settings,
# then they will be inherited by projects' .toml files.
@@ -72,6 +78,139 @@ ignore = [
"UP032", # Replace .format with f-string
]
+[tool.ty.rules]
+all = "ignore"
+
+# Structural rules (no deep type inference needed, easier to adapt).
+abstract-method-in-final-class = "error"
+ambiguous-protocol-member = "error"
+byte-string-type-annotation = "error"
+conflicting-declarations = "error"
+conflicting-metaclass = "error"
+cyclic-class-definition = "error"
+cyclic-type-alias-definition = "error"
+dataclass-field-order = "error"
+duplicate-base = "error"
+duplicate-kw-only = "error"
+empty-body = "error"
+escape-character-in-forward-annotation = "error"
+final-on-non-method = "error"
+final-without-value = "error"
+fstring-type-annotation = "error"
+ignore-comment-unknown-rule = "error"
+implicit-concatenated-string-type-annotation = "error"
+inconsistent-mro = "error"
+ineffective-final = "error"
+instance-layout-conflict = "error"
+invalid-dataclass = "error"
+invalid-dataclass-override = "error"
+invalid-enum-member-annotation = "error"
+invalid-explicit-override = "error"
+invalid-frozen-dataclass-subclass = "error"
+invalid-generic-class = "error"
+invalid-generic-enum = "error"
+invalid-ignore-comment = "error"
+invalid-legacy-positional-parameter = "error"
+invalid-legacy-type-variable = "error"
+invalid-named-tuple = "error"
+invalid-newtype = "error"
+invalid-overload = "error"
+invalid-paramspec = "error"
+invalid-protocol = "error"
+invalid-syntax-in-forward-annotation = "error"
+invalid-total-ordering = "error"
+invalid-type-alias-type = "error"
+invalid-type-checking-constant = "error"
+invalid-type-guard-definition = "error"
+invalid-type-variable-bound = "error"
+invalid-type-variable-constraints = "error"
+invalid-typed-dict-header = "error"
+invalid-typed-dict-statement = "error"
+override-of-final-method = "error"
+override-of-final-variable = "error"
+possibly-missing-import = "error"
+possibly-missing-submodule = "error"
+# Has false positives due to ty walrus operator bug.
+# possibly-unresolved-reference = "error"
+raw-string-type-annotation = "error"
+redundant-final-classvar = "error"
+shadowed-type-variable = "error"
+subclass-of-final-class = "error"
+super-call-in-named-tuple-method = "error"
+unavailable-implicit-super-arguments = "error"
+unbound-type-variable = "error"
+undefined-reveal = "error"
+unresolved-global = "error"
+unresolved-import = "error"
+unresolved-reference = "error"
+unused-ignore-comment = "error"
+unused-type-ignore-comment = "error"
+useless-overload-body = "error"
+
+# Non-structural rules:
+deprecated = "error"
+zero-stepsize-in-slice = "error"
+possibly-missing-implicit-call = "error"
+unused-awaitable = "error"
+
+# Function argument rules:
+# Conflicts with `ifcopenshell.api.geometry.add_representation` type of callables we have, confusing them with a module.
+# call-non-callable = "error"
+conflicting-argument-forms = "error"
+# Too many false positives.
+# invalid-argument-type = "error"
+missing-argument = "error"
+parameter-already-assigned = "error"
+positional-only-parameter-as-kwarg = "error"
+too-many-positional-arguments = "error"
+unknown-argument = "error"
+# Has a lot of warnings due to current ty walrus operator issues.
+# index-out-of-bounds = "error"
+# unresolved-attribute = "error"
+
+[tool.ty.environment]
+extra-paths = [
+ "src/bonsai/external_dependencies",
+ "src/bcf",
+ "src/bsdd",
+ "src/bonsai",
+ "src/ifc4d",
+ "src/ifc5d",
+ "src/ifccityjson",
+ "src/ifcclash",
+ "src/ifccsv",
+ "src/ifcdiff",
+ "src/ifcfm",
+ "src/ifcopenshell-python",
+ "src/ifcpatch",
+ "src/ifctester",
+]
+
+[tool.ty.src]
+exclude = [
+ # External dependencies cloned for type checking only.
+ "src/bonsai/external_dependencies",
+ # Submodules.
+ "src/ifcopenshell-python/ifcopenshell/express",
+ "src/ifcopenshell-python/ifcopenshell/mvd",
+ "src/ifcopenshell-python/ifcopenshell/simple_spf",
+ "src/svgfill/3rdparty",
+ # Has special dependencies.
+ "src/ifcopenshell-python/ifcopenshell/geom/app.py",
+ "src/ifcopenshell-python/ifcopenshell/geom/code_editor_pane.py",
+ "src/ifcopenshell-python/ifcopenshell/util/doc.py",
+ "src/ifcopenshell-python/ifcopenshell/util/generate_pset_templates.py",
+ "src/ifcopenshell-python/ifcopenshell/util/ifc4x3dev_scrape_data_for_docs.py",
+ # Too esoteric.
+ "src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.py",
+ "src/ifc2ca/templates",
+ # Too dev.
+ "src/bcf/setup.py",
+ "src/bsdd/yml_to_classes.py",
+ # Deprecated.
+ "src/ifc2ca/_deprecated",
+]
+
[tool.poe.tasks]
ruff-main = "ruff check --extend-exclude nix/build-all.py"
@@ -81,6 +220,47 @@ ruff.sequence = ["ruff-main", "ruff-old"]
black = "black ."
+ty.sequence = ["ty-bonsai", "ty-ios"]
+ty.help = "Run ty type checker. Requires ty-venv to be set up first."
+ty-bonsai = "ty check src/bonsai --python=src/bonsai/.venv"
+
+ty-venv.sequence = ["ty-venv-bonsai", "ty-venv-ios"]
+
+ty-venv-bonsai.sequence = [
+ {cmd = "uv venv src/bonsai/.venv --python=3.11 --allow-existing"},
+ {cmd = "uv pip install -r src/bonsai/type-check-requirements.txt --python=src/bonsai/.venv"},
+]
+
+ty-venv-ios.sequence = [
+ {cmd = "uv venv src/ifcopenshell-python/.venv --python=3.10 --allow-existing"},
+ {cmd = "uv pip install -r src/ifcopenshell-python/type-check-requirements.txt --python=src/ifcopenshell-python/.venv"},
+]
+
format.sequence = ["black", "ruff-main", "ruff-old"]
cmake-format = "gersemi . --in-place"
+
+[tool.poe.tasks.ty-ios]
+# --ignore unresolved-reference: walrus operator false positives in ty.
+cmd = """
+ ty check
+ src/bcf
+ src/bsdd
+ src/ifc2ca
+ src/ifc4d
+ src/ifc5d
+ src/ifccityjson
+ src/ifcclash
+ src/ifccsv
+ src/ifcdiff
+ src/ifcfm
+ src/ifcopenshell-python
+ src/ifcpatch
+ src/ifctester
+ --python=src/ifcopenshell-python/.venv
+ --ignore unresolved-reference
+"""
+
+[tool.poe.tasks.bonsai-deps]
+help = "Clone or update Bonsai external dependencies."
+cmd = "python src/bonsai/scripts/bonsai_deps.py"
diff --git a/src/bcf/bcf/v3/bcfapi.py b/src/bcf/bcf/v3/bcfapi.py
index 3ac85b9685..368c294cd0 100644
--- a/src/bcf/bcf/v3/bcfapi.py
+++ b/src/bcf/bcf/v3/bcfapi.py
@@ -34,8 +34,8 @@ client_id, client_secret = "", ""
class OAuthReceiver(http.server.BaseHTTPRequestHandler):
def do_GET(self) -> None:
query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
- self.server.auth_code = query.get("code", [""])[0] # type: ignore
- self.server.auth_state = query.get("state", [""])[0] # type: ignore
+ self.server.auth_code = query.get("code", [""])[0]
+ self.server.auth_state = query.get("state", [""])[0]
self.send_response(200)
self.send_header("Content-type", "text/plain")
self.end_headers()
@@ -255,7 +255,7 @@ class BcfClient:
project_id: str = "",
topics: str = "",
query_string: Optional[str] = None,
- ) -> list[Any]:
+ ) -> None:
# return self.get(
# f"/projects/{project_id}/topics",
# {
diff --git a/src/bcf/tests/v3/test_example_files.py b/src/bcf/tests/v3/test_example_files.py
index 3f141afda6..06eb5d22dd 100644
--- a/src/bcf/tests/v3/test_example_files.py
+++ b/src/bcf/tests/v3/test_example_files.py
@@ -173,16 +173,17 @@ def assert_viewpoints(viewpoints):
assert viewpoint.snapshot is not None
+# TODO: dead code - ported from v2 but buildingSMART/BCF-XML has no v3 MaximumInformation.bcf equivalent
def assert_second_viewpoint(viewpoint, expected_selection, expected_exception, expected_coloring) -> None:
expected_vp = mdl.VisualizationInfo(
components=mdl.Components(
- view_setup_hints=mdl.ViewSetupHints(
- spaces_visible=False,
- space_boundaries_visible=False,
- openings_visible=False,
- ),
selection=expected_selection,
visibility=mdl.ComponentVisibility(
+ view_setup_hints=mdl.ViewSetupHints(
+ spaces_visible=False,
+ space_boundaries_visible=False,
+ openings_visible=False,
+ ),
exceptions=expected_exception,
default_visibility=False,
),
@@ -193,6 +194,7 @@ def assert_second_viewpoint(viewpoint, expected_selection, expected_exception, e
camera_direction=mdl.Direction(x=0.6745243072509766, y=-0.6599355936050415, z=-0.33091068267822266),
camera_up_vector=mdl.Direction(x=0.2271970510482788, y=-0.24091780185699463, z=0.9435783624649048),
field_of_view=60,
+ aspect_ratio=1.0,
),
guid="21dd4807-e9af-439e-a980-04d913a6b1ce",
)
@@ -200,16 +202,17 @@ def assert_second_viewpoint(viewpoint, expected_selection, expected_exception, e
assert viewpoint.snapshot is not None
+# TODO: dead code - ported from v2 but buildingSMART/BCF-XML has no v3 MaximumInformation.bcf equivalent
def assert_third_viewpoint(viewpoint, expected_selection, expected_exception, expected_coloring) -> None:
expected_vp = mdl.VisualizationInfo(
components=mdl.Components(
- view_setup_hints=mdl.ViewSetupHints(
- spaces_visible=False,
- space_boundaries_visible=False,
- openings_visible=True,
- ),
selection=expected_selection,
visibility=mdl.ComponentVisibility(
+ view_setup_hints=mdl.ViewSetupHints(
+ spaces_visible=False,
+ space_boundaries_visible=False,
+ openings_visible=True,
+ ),
exceptions=expected_exception,
default_visibility=True,
),
@@ -220,6 +223,7 @@ def assert_third_viewpoint(viewpoint, expected_selection, expected_exception, ex
camera_direction=mdl.Direction(x=0.7232745289802551, y=0.5967116951942444, z=-0.3475759029388428),
camera_up_vector=mdl.Direction(x=0.27662187814712524, y=0.21082592010498047, z=0.937567412853241),
field_of_view=60,
+ aspect_ratio=1.0,
),
guid="81daa431-bf01-4a49-80a2-1ab07c177717",
)
diff --git a/src/bonsai/Makefile b/src/bonsai/Makefile
index 310d31a4b0..b517bc572b 100644
--- a/src/bonsai/Makefile
+++ b/src/bonsai/Makefile
@@ -48,6 +48,7 @@ VERSION_PATCH:=$(shell cat '../../VERSION' | cut -d '.' -f 3)
VERSION_DATE:=$(shell date '+%y%m%d')
LAST_COMMIT_HASH:=$(shell git rev-parse HEAD)
LAST_COMMIT_DATE:=$(shell git show -s --format=%cI)
+LAST_GIT_BRANCH:=$(shell git rev-parse --abbrev-ref HEAD)
PYPI_IMP:=cp
ifdef PYVERSION
@@ -63,6 +64,7 @@ PYNUMBER:=3$(PYMINOR)
PYPI_VERSION:=3.$(PYMINOR)
endif # def PYVERSION
+IFCMERGE_VERSION:=2026-04-02
ifdef PLATFORM
SUPPORTED_PLATFORMS := linux macos macosm1 win
@@ -223,19 +225,8 @@ endif
cd build/bonsai/bim/data/gantt/ && wget https://raw.githubusercontent.com/jsGanttImproved/jsgantt-improved/master/dist/jsgantt.css
# Provides IFCJSON functionality
- cd build && wget -O ifc2json.zip https://github.com/IFCJSON-Team/IFC2JSON_python/archive/refs/heads/master.zip
- cd build && unzip ifc2json.zip && rm ifc2json.zip
- # IFCJSON doesn't have pyproject.toml, so we use python command.
- cd build && . env/$(VENV_ACTIVATE) && cd IFC2JSON_python-*/file_converters && \
- $(PYTHON) -c "from setuptools import setup; \
- setup( \
- name='ifcjson', \
- version='0.0.1', \
- author='Jan Brouwer', \
- author_email='jan@brewsky.nl', \
- packages=['ifcjson'], \
- )" bdist_wheel
- cp -r build/IFC2JSON_python-*/file_converters/dist/*.whl build/wheels/
+ # TODO: Use official repo, once https://github.com/IFCJSON-Team/IFC2JSON_python/pull/8 is merged.
+ cd build && . env/$(VENV_ACTIVATE) && $(PYTHON) -m pip wheel "git+https://github.com/Andrej730/IFC2JSON_python.git@pyproject_toml" --no-deps -w wheels/
# Brickschema requires pkg_resources which is provided by Blender.
# Provides Brickschema functionality
@@ -243,26 +234,16 @@ endif
cd build/bonsai/bim/data/brick/ && wget https://github.com/BrickSchema/Brick/releases/download/nightly/Brick.ttl
# Required for hipped roof generation
- cd build && wget https://github.com/prochitecture/bpypolyskel/archive/refs/heads/master.zip
- cd build && unzip master.zip && rm master.zip
- cd build && . env/$(VENV_ACTIVATE) && cd bpypolyskel-master && \
- $(PYTHON) -c "from setuptools import setup; \
- setup( \
- name='bpypolyskel', \
- version='0.0.0', \
- packages=['bpypolyskel'], \
- )" bdist_wheel
- cp -r build/bpypolyskel-master/dist/*.whl build/wheels/
+ cd build && . env/$(VENV_ACTIVATE) && $(PYTHON) -m pip wheel "git+https://github.com/prochitecture/bpypolyskel" --no-deps -w wheels/
# folder for executable files
mkdir -p build/bonsai/libs/bin
# required for three-way git merging
ifeq ($(PLATFORM), win)
- cd build/bonsai/libs/bin && wget https://github.com/brunopostle/ifcmerge/releases/download/2025-01-26/ifcmerge.zip
- cd build/bonsai/libs/bin && unzip ifcmerge.zip && rm ifcmerge.zip
+ cd build/bonsai/libs/bin && wget https://github.com/brunopostle/ifcmerge/releases/download/$(IFCMERGE_VERSION)/ifcmerge.exe
else
- cd build/bonsai/libs/bin && wget https://raw.githubusercontent.com/brunopostle/ifcmerge/main/ifcmerge && chmod +x ifcmerge
+ cd build/bonsai/libs/bin && wget https://raw.githubusercontent.com/brunopostle/ifcmerge/$(IFCMERGE_VERSION)/ifcmerge && chmod +x ifcmerge
endif
# Generate translations module for Bonsai build
@@ -281,6 +262,7 @@ else
$(SED) "s/0.0.0/$(VERSION)-alpha$(VERSION_DATE)/" build/bonsai/blender_manifest.toml
$(SED) "s/8888888/$(LAST_COMMIT_HASH)/" build/bonsai/__init__.py
$(SED) "s/9999999/$(LAST_COMMIT_DATE)/" build/bonsai/__init__.py
+ $(SED) "s/7777777/$(LAST_GIT_BRANCH)/" build/bonsai/__init__.py
$(SED) 's/version = "0.0.0"/version = "$(VERSION)-alpha$(VERSION_DATE)"/' build/pyproject.toml
endif
diff --git a/src/bonsai/bonsai/__init__.py b/src/bonsai/bonsai/__init__.py
index e30e2bf699..c6ec6405c0 100644
--- a/src/bonsai/bonsai/__init__.py
+++ b/src/bonsai/bonsai/__init__.py
@@ -35,15 +35,15 @@ IN_PACKAGE = __package__ == "bonsai"
import platform
import re
import traceback
-import uuid
import webbrowser
from collections import deque
from collections.abc import Generator
from pathlib import Path
-from typing import Any, Union
+from typing import TYPE_CHECKING, Any, Union
last_commit_hash = "8888888"
last_commit_date = "9999999"
+last_git_branch = "7777777"
def get_last_commit_hash() -> Union[str, None]:
@@ -61,6 +61,15 @@ def get_last_commit_date() -> Union[str, None]:
return last_commit_date
+def get_git_branch() -> Union[str, None]:
+ # Using this weird way to write 7777777,
+ # so makefile won't accidentally replace it here
+ # we'll be able to distinguish branch from placeholder value.
+ if last_git_branch == str(7_777777):
+ return None
+ return last_git_branch
+
+
# Accessed from bonsai extension:
bbim_semver: dict[str, Any] = {}
@@ -72,6 +81,21 @@ REINSTALLED_BBIM_VERSION: Union[str, None] = None
REGISTERED_BBIM_PACKAGE: str
+def is_registering() -> bool:
+ """
+ During addon registration ``bpy.context`` and ``bpy.data`` are restricted
+ and you can't access their properties.
+ """
+ import bpy
+
+ if TYPE_CHECKING or bpy.app.version >= (5, 0, 0):
+ import _bpy_restrict_state as bpy_restrict_state
+ else:
+ import bpy_restrict_state
+
+ return isinstance(bpy.context, bpy_restrict_state._RestrictContext)
+
+
def initialize_bbim_semver():
"""Initialize `bbim_semver` dictionary.
@@ -93,9 +117,13 @@ def initialize_bbim_semver():
bbim_semver["version"] = version_str
-def get_debug_info():
+def get_debug_info(*, bonsai_failed_to_load: bool = False) -> dict[str, Any]:
+ import bpy
+
bbim_version = bbim_semver["version"]
+ # All data here should be gettable even in case of `bpy.context` and `bpy.data` being inaccessible
+ # and Bonsai completely failed to load.
debug_info = {
"os": platform.system(),
"os_version": platform.version(),
@@ -107,10 +135,19 @@ def get_debug_info():
"bonsai_version": bbim_version,
"bonsai_commit_hash": get_last_commit_hash(),
"bonsai_commit_date": get_last_commit_date(),
+ "bonsai_git_branch": get_git_branch(),
"last_actions": last_actions,
"last_error": last_error,
}
+ # Can't access blend data or context during registration.
+ # If Bonsai failed to load we cannot safely access any of its properties or its tools
+ # as they may not be registered yet and acessing them will break Bonsai Fatal Error UI.
+ if is_registering() or bonsai_failed_to_load:
+ return debug_info
+
+ import bonsai.tool as tool
+
# Add .blend file save information
if bpy.data.is_saved:
debug_info["blend_file_path"] = bpy.data.filepath
@@ -131,7 +168,7 @@ def get_debug_info():
return debug_info
-def format_debug_info(info: dict):
+def format_debug_info(info: dict[str, Any]) -> str:
last_actions = ""
for action in info["last_actions"]:
last_actions += f"\n# {action['type']}: {action['name']}"
@@ -149,33 +186,10 @@ def get_binaries(path: Path) -> Generator[Path, None, None]:
yield from path.glob("**/*.so")
-def safe_link_dlls() -> None:
- # Blender 4.2+ has a problem on Windows for disabling/enabling/reinstalling extensions
- # with loaded binary dependencies (on Windows you can't remove a binary if it's loaded by some program).
- # To avoid this issue we temporary hard link dlls to our temp directory on unregister()
- # (unregister is executed before Blender will try to uninstall dependencies and the issue will arise).
- # Then, Blender won't have a problem unlinking unloaded dlls as they are still linked somewhere.
- # On register() we clean up our temp directory with binaries.
- #
- # TODO: If user uninstalls Bonsai to never use it again, temporary directory won't be cleared.
- #
- # See: https://projects.blender.org/blender/blender/issues/125049
- import bpy
-
- ext_path = Path(bpy.utils.user_resource("EXTENSIONS"))
- local_path = ext_path / ".local"
-
- # We use random hash subfolder as user may try to enable/disable addon multiple times.
- random_hash = uuid.uuid4().hex[:8]
- temp_local = ext_path / ".local_temp" / random_hash
- temp_local.mkdir(parents=True)
-
- for filepath in get_binaries(local_path):
- dest_path = temp_local / filepath.relative_to(local_path)
- dest_path.parent.mkdir(exist_ok=True, parents=True)
- os.link(filepath, dest_path)
-
-
+# TODO: remove before 0.8.6 release.
+# On Windows issues with removing extensions were resolved in Blender 4.3,
+# but we removed our workaround that was producing some junk only in 0.8.5 release.
+# So we're temporarily keeping the part that's cleaning up outputs from previous releases.
def clean_up_dlls_safe_links() -> None:
import bpy
@@ -205,6 +219,8 @@ def clean_up_dlls_safe_links() -> None:
if IN_BLENDER:
+ import bpy
+
initialize_bbim_semver()
def get_binary_info() -> dict[str, Any]:
@@ -246,10 +262,12 @@ if IN_BLENDER:
global last_commit_hash
global last_commit_date
+ global last_git_branch
path = Path(__file__).resolve().parent
repo = git.Repo(str(path), search_parent_directories=True)
last_commit_hash = repo.head.object.hexsha
last_commit_date = repo.head.object.committed_datetime.isoformat()
+ last_git_branch = repo.active_branch.name
except:
pass
@@ -296,9 +314,6 @@ if IN_BLENDER:
purge_cache()
def unregister():
- if platform.system() == "Windows":
- safe_link_dlls()
-
import bonsai.bim
bonsai.bim.unregister()
@@ -333,7 +348,7 @@ if IN_BLENDER:
bl_context = "scene"
def draw(self, context):
- info = get_debug_info()
+ info = get_debug_info(bonsai_failed_to_load=True)
layout = self.layout
layout.alert = True
@@ -409,7 +424,7 @@ if IN_BLENDER:
bl_description = "Copies debugging information to your clipboard for use in bugreports"
def execute(self, context):
- info = get_debug_info()
+ info = get_debug_info(bonsai_failed_to_load=True)
info.update(get_binary_info())
info = format_debug_info(info)
context.window_manager.clipboard = info
diff --git a/src/bonsai/bonsai/bim/export_ifc.py b/src/bonsai/bonsai/bim/export_ifc.py
index 633d6292f9..f28c106e95 100644
--- a/src/bonsai/bonsai/bim/export_ifc.py
+++ b/src/bonsai/bonsai/bim/export_ifc.py
@@ -72,9 +72,7 @@ class IfcExporter:
def set_header(self):
self.file.header.file_name.name = os.path.basename(self.ifc_export_settings.output_file)
- self.file.header.file_name.time_stamp = (
- datetime.datetime.utcnow().replace(tzinfo=datetime.UTC).astimezone().replace(microsecond=0).isoformat()
- )
+ self.file.header.file_name.time_stamp = datetime.datetime.now().astimezone().replace(microsecond=0).isoformat()
self.file.header.file_name.preprocessor_version = "IfcOpenShell {}".format(ifcopenshell.version)
self.file.header.file_name.originating_system = "{} {}".format(
self.get_application_name(), tool.Blender.get_bonsai_version()
diff --git a/src/bonsai/bonsai/bim/handler.py b/src/bonsai/bonsai/bim/handler.py
index 231b44c671..e11eb07ce8 100644
--- a/src/bonsai/bonsai/bim/handler.py
+++ b/src/bonsai/bonsai/bim/handler.py
@@ -45,16 +45,19 @@ from bonsai.bim.module.nest.decorator import NestDecorator
cwd = os.path.dirname(os.path.realpath(__file__))
global_subscription_owner = object()
+# Separate owner for per-object msgbus subscriptions (name, active_material_index).
+# Using a dedicated owner allows clearing all per-object subscriptions at once
+# during undo/redo without affecting other global subscriptions.
+object_subscription_owner = object()
def name_callback(obj: Union[bpy.types.Object, bpy.types.Material], data: str) -> None:
try:
obj.name
except:
- # The object is invalid but somehow still has a callback. Clear all
- # msgbus subscriptions to prevent useless further triggers.
- bpy.msgbus.clear_by_owner(obj)
- return # In case the object RNA is gone during an undo / redo operation
+ # The object is invalid but somehow still has a callback.
+ # This can occur during undo/redo when the Python wrapper is stale.
+ return
# Blender names are up to 63 UTF-8 bytes
if len(bytes(obj.name, "utf-8")) >= 63:
return
@@ -189,7 +192,7 @@ def subscribe_to(obj: bpy.types.ID, data_path: str, callback: Callable[[bpy.type
return
bpy.msgbus.subscribe_rna(
key=subscribe_to,
- owner=obj,
+ owner=object_subscription_owner,
args=(
obj,
data_path,
diff --git a/src/bonsai/bonsai/bim/ifc.py b/src/bonsai/bonsai/bim/ifc.py
index f7d23e1dbe..b07e584a71 100644
--- a/src/bonsai/bonsai/bim/ifc.py
+++ b/src/bonsai/bonsai/bim/ifc.py
@@ -316,11 +316,8 @@ class IfcStore:
del IfcStore.id_map[data["id"]]
if "guid" in data:
del IfcStore.guid_map[data["guid"]]
- obj = IfcStore.get_object_by_name(data["obj"])
- if obj is None:
- # obj was just created during this step and didn't existed before.
- return
- bpy.msgbus.clear_by_owner(obj)
+ # Note: msgbus subscriptions are cleared globally during
+ # rebuild_element_maps which runs after every undo/redo.
@staticmethod
def commit_link_element(data: OperationData) -> None:
@@ -367,10 +364,8 @@ class IfcStore:
del IfcStore.id_map[data["id"]]
if "guid" in data:
del IfcStore.guid_map[data["guid"]]
- obj = IfcStore.get_object_by_name(data["obj"])
- # obj might be removed after unlink.
- if not obj:
- bpy.msgbus.clear_by_owner(obj)
+ # Note: msgbus subscriptions are cleared globally during
+ # rebuild_element_maps which runs after every undo/redo.
@staticmethod
def unlink_element(
diff --git a/src/bonsai/bonsai/bim/import_ifc.py b/src/bonsai/bonsai/bim/import_ifc.py
index 7e8f40560e..267800469a 100644
--- a/src/bonsai/bonsai/bim/import_ifc.py
+++ b/src/bonsai/bonsai/bim/import_ifc.py
@@ -64,8 +64,8 @@ class MaterialCreator:
mesh: Union[OBJECT_DATA_TYPE, None],
shape_has_openings: bool,
) -> None:
- if ((rep := getattr(element, "Representation", ...) is not ...) and not rep) or (
- (rep := getattr(element, "RepresentationMaps", ...) is not ...) and not rep
+ if ((rep := getattr(element, "Representation", ...)) is not ... and not rep) or (
+ (rep := getattr(element, "RepresentationMaps", ...)) is not ... and not rep
):
return
@@ -747,6 +747,7 @@ class IfcImporter:
self.update_progress((percent_average / 100 * progress_range) + start_progress)
shape = iterator.get()
if shape:
+ assert isinstance(shape, W.TriangulationElement)
product = self.file.by_id(shape.id)
self.create_product(product, shape)
results.add(product)
@@ -1020,7 +1021,8 @@ class IfcImporter:
obj.hide_select = True
obj.hide_viewport = True
self.project["blender"].objects.link(obj)
- self.project["blender"].BIMCollectionProperties.obj = obj
+ collection_props = tool.Blender.get_collection_props(self.project["blender"])
+ collection_props.obj = obj
props = tool.Blender.get_object_bim_props(obj)
props.collection = self.collections[project.GlobalId] = self.project["blender"]
diff --git a/src/bonsai/bonsai/bim/module/aggregate/decorator.py b/src/bonsai/bonsai/bim/module/aggregate/decorator.py
index eb389a58bd..2cd1c1bca0 100644
--- a/src/bonsai/bonsai/bim/module/aggregate/decorator.py
+++ b/src/bonsai/bonsai/bim/module/aggregate/decorator.py
@@ -101,7 +101,7 @@ class AggregateDecorator:
cls.is_installed = False
def dotted_line_shader(self):
- vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
+ vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments]
vert_out.smooth("FLOAT", "v_ArcLength")
shader_info = gpu.types.GPUShaderCreateInfo()
diff --git a/src/bonsai/bonsai/bim/module/augin/prop.py b/src/bonsai/bonsai/bim/module/augin/prop.py
index 9878909eb0..6358371613 100644
--- a/src/bonsai/bonsai/bim/module/augin/prop.py
+++ b/src/bonsai/bonsai/bim/module/augin/prop.py
@@ -16,8 +16,9 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see .
-import bpy.props
-import bpy.types
+from typing import TYPE_CHECKING
+
+import bpy
class AuginProperties(bpy.types.PropertyGroup):
@@ -27,3 +28,11 @@ class AuginProperties(bpy.types.PropertyGroup):
project_name: bpy.props.StringProperty(name="Project Name")
project_filename: bpy.props.StringProperty(name="IFC Filename")
is_success: bpy.props.BoolProperty(name="Is Successful Upload", default=False)
+
+ if TYPE_CHECKING:
+ username: str
+ password: str
+ token: str
+ project_name: str
+ project_filename: str
+ is_success: bool
diff --git a/src/bonsai/bonsai/bim/module/bcf/operator.py b/src/bonsai/bonsai/bim/module/bcf/operator.py
index 6edf257f05..461321852d 100644
--- a/src/bonsai/bonsai/bim/module/bcf/operator.py
+++ b/src/bonsai/bonsai/bim/module/bcf/operator.py
@@ -1253,8 +1253,8 @@ class ActivateBcfViewpoint(bpy.types.Operator):
else:
obj.data.show_background_images = False
- area = next(area for area in context.screen.areas if area.type == "VIEW_3D")
- area.spaces[0].region_3d.view_perspective = "CAMERA"
+ assert (space := tool.Blender.get_view3d_space())
+ space.region_3d.view_perspective = "CAMERA"
if self.file:
self.set_viewpoint_components(viewpoint, context)
diff --git a/src/bonsai/bonsai/bim/module/bcf/prop.py b/src/bonsai/bonsai/bim/module/bcf/prop.py
index 3ac751387e..651c0c052f 100644
--- a/src/bonsai/bonsai/bim/module/bcf/prop.py
+++ b/src/bonsai/bonsai/bim/module/bcf/prop.py
@@ -230,7 +230,7 @@ class BcfTopic(PropertyGroup):
def get_related_topics(self: "BCFProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]:
- global RELATED_TOPICS_ENUM_ITEMS
+ global RELATED_TOPICS_ENUM_ITEMS # ty: ignore[unresolved-global]
props = self
active_topic = props.active_topic
active_related_topics = active_topic.related_topics.keys()
diff --git a/src/bonsai/bonsai/bim/module/boundary/operator.py b/src/bonsai/bonsai/bim/module/boundary/operator.py
index 5720d6aae5..45ee8c60e2 100644
--- a/src/bonsai/bonsai/bim/module/boundary/operator.py
+++ b/src/bonsai/bonsai/bim/module/boundary/operator.py
@@ -27,6 +27,7 @@ import ifcopenshell.api
import ifcopenshell.api.boundary
import ifcopenshell.api.root
import ifcopenshell.geom
+import ifcopenshell.ifcopenshell_wrapper as W
import ifcopenshell.util.element
import ifcopenshell.util.placement
import ifcopenshell.util.shape
@@ -376,6 +377,8 @@ class EnableEditingBoundary(bpy.types.Operator):
obj = tool.Ifc.get_object(entity)
if entity and obj:
setattr(bprops, blender_property, obj)
+ bprops.physical_or_virtual = boundary.PhysicalOrVirtualBoundary or "NOTDEFINED"
+ bprops.internal_or_external = boundary.InternalOrExternalBoundary or "NOTDEFINED"
return {"FINISHED"}
@@ -391,6 +394,8 @@ class DisableEditingBoundary(bpy.types.Operator):
bprops.is_editing = False
for ifc_attribute, blender_property in EDITABLE_ATTRIBUTES.items():
setattr(bprops, blender_property, None)
+ bprops.physical_or_virtual = "NOTDEFINED"
+ bprops.internal_or_external = "NOTDEFINED"
return {"FINISHED"}
@@ -410,6 +415,8 @@ class EditBoundaryAttributes(bpy.types.Operator, tool.Ifc.Operator):
obj = getattr(bprops, blender_property, None)
entity = tool.Ifc.get_entity(obj)
attributes[blender_property] = entity
+ attributes["physical_or_virtual"] = bprops.physical_or_virtual
+ attributes["internal_or_external"] = bprops.internal_or_external
ifcopenshell.api.boundary.edit_attributes(tool.Ifc.get(), entity=boundary, **attributes)
bpy.ops.bim.disable_editing_boundary()
return {"FINISHED"}
@@ -701,6 +708,7 @@ class AddBoundary(bpy.types.Operator, tool.Ifc.Operator):
while True:
tree.add_element(iterator.get_native())
shape = iterator.get()
+ assert isinstance(shape, W.TriangulationElement)
shapes[shape.id] = {
"verts": ifcopenshell.util.shape.get_vertices(shape.geometry),
"faces": ifcopenshell.util.shape.get_faces(shape.geometry),
diff --git a/src/bonsai/bonsai/bim/module/boundary/prop.py b/src/bonsai/bonsai/bim/module/boundary/prop.py
index 2e9ab8c975..7b4f5069ad 100644
--- a/src/bonsai/bonsai/bim/module/boundary/prop.py
+++ b/src/bonsai/bonsai/bim/module/boundary/prop.py
@@ -21,6 +21,7 @@ from typing import TYPE_CHECKING, Union
import bpy
from bpy.props import (
BoolProperty,
+ EnumProperty,
PointerProperty,
)
from bpy.types import PropertyGroup
@@ -50,12 +51,43 @@ def element_filter(self: "BIMObjectBoundaryProperties", object: bpy.types.Object
return False
+def get_internal_or_external_items(
+ self: "BIMObjectBoundaryProperties", context: bpy.types.Context | None
+) -> list[tuple[str, str, str]]:
+ items = [
+ ("INTERNAL", "Internal", ""),
+ ("EXTERNAL", "External", ""),
+ ]
+ ifc = tool.Ifc.get()
+ if not ifc or ifc.schema != "IFC2X3":
+ items += [
+ ("EXTERNAL_EARTH", "External Earth", ""),
+ ("EXTERNAL_WATER", "External Water", ""),
+ ("EXTERNAL_FIRE", "External Fire", ""),
+ ]
+ items.append(("NOTDEFINED", "Not Defined", ""))
+ return items
+
+
class BIMObjectBoundaryProperties(PropertyGroup):
is_editing: BoolProperty(name="Is Editing")
relating_space: PointerProperty(name="RelatingSpace", type=bpy.types.Object, poll=space_filter)
related_building_element: PointerProperty(name="RelatedBuildingElement", type=bpy.types.Object, poll=element_filter)
parent_boundary: PointerProperty(name="ParentBoundary", type=bpy.types.Object, poll=boundary_filter)
corresponding_boundary: PointerProperty(name="CorrespondingBoundary", type=bpy.types.Object, poll=boundary_filter)
+ physical_or_virtual: EnumProperty(
+ name="PhysicalOrVirtualBoundary",
+ items=[
+ ("PHYSICAL", "Physical", ""),
+ ("VIRTUAL", "Virtual", ""),
+ ("NOTDEFINED", "Not Defined", ""),
+ ],
+ default="NOTDEFINED",
+ )
+ internal_or_external: EnumProperty(
+ name="InternalOrExternalBoundary",
+ items=get_internal_or_external_items,
+ )
if TYPE_CHECKING:
is_editing: bool
@@ -63,6 +95,8 @@ class BIMObjectBoundaryProperties(PropertyGroup):
related_building_element: Union[bpy.types.Object, None]
parent_boundary: Union[bpy.types.Object, None]
corresponding_boundary: Union[bpy.types.Object, None]
+ physical_or_virtual: str
+ internal_or_external: str # values depend on schema: IFC2X3 omits EXTERNAL_EARTH/WATER/FIRE
class BIMBoundaryProperties(PropertyGroup):
diff --git a/src/bonsai/bonsai/bim/module/boundary/ui.py b/src/bonsai/bonsai/bim/module/boundary/ui.py
index 0ca21b9ce5..91990a5eb0 100644
--- a/src/bonsai/bonsai/bim/module/boundary/ui.py
+++ b/src/bonsai/bonsai/bim/module/boundary/ui.py
@@ -77,6 +77,10 @@ class BIM_PT_Boundary(Panel):
self.draw_relation_editor(boundary, "RelatedBuildingElement", "related_building_element")
self.draw_relation_editor(boundary, "ParentBoundary", "parent_boundary")
self.draw_relation_editor(boundary, "CorrespondingBoundary", "corresponding_boundary")
+ row = self.layout.row()
+ row.prop(self.bprops, "physical_or_virtual")
+ row = self.layout.row()
+ row.prop(self.bprops, "internal_or_external")
else:
row = self.layout.row()
row.operator("bim.enable_editing_boundary", icon="GREASEPENCIL", text="Edit")
@@ -84,6 +88,8 @@ class BIM_PT_Boundary(Panel):
self.draw_relation_data(boundary, "RelatedBuildingElement")
self.draw_relation_data(boundary, "ParentBoundary")
self.draw_relation_data(boundary, "CorrespondingBoundary")
+ self.draw_enum_data(boundary, "PhysicalOrVirtualBoundary")
+ self.draw_enum_data(boundary, "InternalOrExternalBoundary")
if hasattr(boundary, "InnerBoundaries"):
for i, inner_boundary in enumerate(getattr(boundary, "InnerBoundaries", ())):
row = self.layout.row(align=True)
@@ -110,6 +116,11 @@ class BIM_PT_Boundary(Panel):
else:
row.label(text="")
+ def draw_enum_data(self, boundary, ifc_attribute: str):
+ row = self.layout.row(align=True)
+ row.label(text=ifc_attribute)
+ row.label(text=getattr(boundary, ifc_attribute, "") or "")
+
def draw_relation_editor(self, boundary, ifc_attribute: str, blender_property: str):
if hasattr(boundary, ifc_attribute):
row = self.layout.row(align=True)
diff --git a/src/bonsai/bonsai/bim/module/brick/prop.py b/src/bonsai/bonsai/bim/module/brick/prop.py
index 6201113a09..2d507411e4 100644
--- a/src/bonsai/bonsai/bim/module/brick/prop.py
+++ b/src/bonsai/bonsai/bim/module/brick/prop.py
@@ -46,26 +46,26 @@ def get_libraries(self, context):
def get_namespaces(self, context):
- global NAMESPACES_ENUM_ITEMS
+ global NAMESPACES_ENUM_ITEMS # ty: ignore[unresolved-global]
NAMESPACES_ENUM_ITEMS = [(uri, f"{alias}: {uri}", "") for alias, uri in BrickStore.namespaces]
return NAMESPACES_ENUM_ITEMS
def get_brick_entity_classes(self, context):
- global ENTITY_CLASSES_ENUM_ITEMS
+ global ENTITY_CLASSES_ENUM_ITEMS # ty: ignore[unresolved-global]
entity = self.brick_entity_create_type
ENTITY_CLASSES_ENUM_ITEMS = [(uri, uri.split("#")[-1], "") for uri in BrickStore.entity_classes[entity]]
return ENTITY_CLASSES_ENUM_ITEMS
def get_brick_roots(self, context):
- global BRICK_ROOTS_ENUM_ITEMS
+ global BRICK_ROOTS_ENUM_ITEMS # ty: ignore[unresolved-global]
BRICK_ROOTS_ENUM_ITEMS = [(root, root, "") for root in BrickStore.root_classes]
return BRICK_ROOTS_ENUM_ITEMS
def get_brick_relations(self, context):
- global BRICK_RELATIONS_ENUM_ITEMS
+ global BRICK_RELATIONS_ENUM_ITEMS # ty: ignore[unresolved-global]
BRICK_RELATIONS_ENUM_ITEMS = [(uri, uri.split("#")[-1], "") for uri in BrickStore.relationships]
for relation in BrickschemaData.data["active_relations"]:
if relation["predicate_name"] == "label":
diff --git a/src/bonsai/bonsai/bim/module/brick/ui.py b/src/bonsai/bonsai/bim/module/brick/ui.py
index aa910c272f..2ec063a0dc 100644
--- a/src/bonsai/bonsai/bim/module/brick/ui.py
+++ b/src/bonsai/bonsai/bim/module/brick/ui.py
@@ -16,9 +16,18 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see .
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import bpy
from bpy.types import Panel, UIList
import bonsai.tool as tool
+
+if TYPE_CHECKING:
+ from bonsai.bim.module.brick.prop import Brick
+
from bonsai.bim.helper import prop_with_search
from bonsai.bim.module.brick.data import BrickschemaData, BrickschemaReferencesData
from bonsai.tool.brick import BrickStore
@@ -274,7 +283,9 @@ class BIM_PT_brickschema_viewport(Panel):
class BIM_UL_bricks(UIList):
split_screen = False
- def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
+ def draw_item(
+ self, context, layout: bpy.types.UILayout, data, item: Brick, icon, active_data, active_propname
+ ) -> None:
if item:
split = layout.split(factor=0.85, align=True)
row = split.row()
diff --git a/src/bonsai/bonsai/bim/module/cad/operator.py b/src/bonsai/bonsai/bim/module/cad/operator.py
index 3b6ef8fc69..bef1d851af 100644
--- a/src/bonsai/bonsai/bim/module/cad/operator.py
+++ b/src/bonsai/bonsai/bim/module/cad/operator.py
@@ -37,6 +37,7 @@ messages = {
class CadTrimExtend(bpy.types.Operator):
bl_idname = "bim.cad_trim_extend"
bl_label = "CAD Trim / Extend"
+ bl_description = "Extends/reduces element to 3D cursor"
@classmethod
def poll(cls, context):
@@ -82,6 +83,7 @@ class CadTrimExtend(bpy.types.Operator):
class CadMitre(bpy.types.Operator):
bl_idname = "bim.cad_mitre"
bl_label = "CAD Mitre"
+ bl_description = "Joins two non-parallel paths at their intersection"
@classmethod
def poll(cls, context):
diff --git a/src/bonsai/bonsai/bim/module/cad/workspace.py b/src/bonsai/bonsai/bim/module/cad/workspace.py
index 20faa4dcc7..24fb98ba3f 100644
--- a/src/bonsai/bonsai/bim/module/cad/workspace.py
+++ b/src/bonsai/bonsai/bim/module/cad/workspace.py
@@ -106,23 +106,37 @@ class CadTool(WorkSpaceTool):
)
row = layout.row(align=True)
- add_layout_hotkey_operator(row, "Extend", "S_E", "Extends/reduces element to 3D cursor", ui_context)
- row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
add_layout_hotkey_operator(
- row, "Join", "S_T", "Joins two non-parallel paths at their intersection", ui_context
+ row, "Extend", "S_E", bpy.ops.bim.cad_trim_extend.__doc__.split("\n", 1)[1].strip(), ui_context
)
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
- add_layout_hotkey_operator(row, "Fillet", "S_F", bpy.ops.bim.add_ifcarcindex_fillet.__doc__, ui_context)
+ add_layout_hotkey_operator(
+ row, "Join", "S_T", bpy.ops.bim.cad_mitre.__doc__.split("\n", 1)[1].strip(), ui_context
+ )
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
- add_layout_hotkey_operator(row, "Offset", "S_O", bpy.ops.bim.cad_offset.__doc__, ui_context)
+ add_layout_hotkey_operator(
+ row, "Fillet", "S_F", bpy.ops.bim.add_ifcarcindex_fillet.__doc__.split("\n", 1)[1].strip(), ui_context
+ )
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
- add_layout_hotkey_operator(row, "Rectangle", "S_R", bpy.ops.bim.add_rectangle.__doc__, ui_context)
+ add_layout_hotkey_operator(
+ row, "Offset", "S_O", bpy.ops.bim.cad_offset.__doc__.split("\n", 1)[1].strip(), ui_context
+ )
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
- add_layout_hotkey_operator(row, "Circle", "S_C", bpy.ops.bim.add_ifccircle.__doc__, ui_context)
+ add_layout_hotkey_operator(
+ row, "Rectangle", "S_R", bpy.ops.bim.add_rectangle.__doc__.split("\n", 1)[1].strip(), ui_context
+ )
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
- add_layout_hotkey_operator(row, "3-Point Arc", "S_V", bpy.ops.bim.set_arc_index.__doc__, ui_context)
+ add_layout_hotkey_operator(
+ row, "Circle", "S_C", bpy.ops.bim.add_ifccircle.__doc__.split("\n", 1)[1].strip(), ui_context
+ )
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
- add_layout_hotkey_operator(row, "Reset Vertex", "S_X", bpy.ops.bim.reset_vertex.__doc__, ui_context)
+ add_layout_hotkey_operator(
+ row, "3-Point Arc", "S_V", bpy.ops.bim.set_arc_index.__doc__.split("\n", 1)[1].strip(), ui_context
+ )
+ row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
+ add_layout_hotkey_operator(
+ row, "Reset Vertex", "S_X", bpy.ops.bim.reset_vertex.__doc__.split("\n", 1)[1].strip(), ui_context
+ )
elif (
isinstance(data, tool.Geometry.TYPES_WITH_MESH_PROPERTIES)
@@ -132,15 +146,21 @@ class CadTool(WorkSpaceTool):
layout, "Edit Axis", "bim.edit_extrusion_axis", "bim.disable_editing_extrusion_axis", ui_context
)
row = layout.row(align=True)
- add_layout_hotkey_operator(row, "Extend", "S_E", "Extends/reduces element to 3D cursor", ui_context)
- row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
add_layout_hotkey_operator(
- row, "Join", "S_T", "Joins two non-parallel paths at their intersection", ui_context
+ row, "Extend", "S_E", bpy.ops.bim.cad_trim_extend.__doc__.split("\n", 1)[1].strip(), ui_context
)
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
- add_layout_hotkey_operator(row, "Fillet", "S_F", bpy.ops.bim.cad_fillet.__doc__, ui_context)
+ add_layout_hotkey_operator(
+ row, "Join", "S_T", bpy.ops.bim.cad_mitre.__doc__.split("\n", 1)[1].strip(), ui_context
+ )
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
- add_layout_hotkey_operator(row, "Offset", "S_O", bpy.ops.bim.cad_offset.__doc__, ui_context)
+ add_layout_hotkey_operator(
+ row, "Fillet", "S_F", bpy.ops.bim.cad_fillet.__doc__.split("\n", 1)[1].strip(), ui_context
+ )
+ row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
+ add_layout_hotkey_operator(
+ row, "Offset", "S_O", bpy.ops.bim.cad_offset.__doc__.split("\n", 1)[1].strip(), ui_context
+ )
else:
if (
@@ -168,19 +188,37 @@ class CadTool(WorkSpaceTool):
add_layout_hotkey_operator(row, "Set Gable Roof Angle", "S_R", "Set Gable Roof Angle", ui_context)
row = layout.row(align=True)
- add_layout_hotkey_operator(row, "Extend", "S_E", "Extends/reduces element to 3D cursor", ui_context)
- row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
add_layout_hotkey_operator(
- row, "Join", "S_T", "Joins two non-parallel paths at their intersection", ui_context
+ row, "Extend", "S_E", bpy.ops.bim.cad_trim_extend.__doc__.split("\n", 1)[1].strip(), ui_context
)
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
- add_layout_hotkey_operator(row, "Fillet", "S_F", bpy.ops.bim.add_ifcarcindex_fillet.__doc__, ui_context)
+ add_layout_hotkey_operator(
+ row, "Join", "S_T", bpy.ops.bim.cad_mitre.__doc__.split("\n", 1)[1].strip(), ui_context
+ )
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
- add_layout_hotkey_operator(row, "Offset", "S_O", bpy.ops.bim.cad_offset.__doc__, ui_context)
+ add_layout_hotkey_operator(
+ row, "Fillet", "S_F", bpy.ops.bim.add_ifcarcindex_fillet.__doc__.split("\n", 1)[1].strip(), ui_context
+ )
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
- add_layout_hotkey_operator(row, "2-Point Arc", "S_C", bpy.ops.bim.cad_arc_from_2_points.__doc__, ui_context)
+ add_layout_hotkey_operator(
+ row, "Offset", "S_O", bpy.ops.bim.cad_offset.__doc__.split("\n", 1)[1].strip(), ui_context
+ )
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
- add_layout_hotkey_operator(row, "3-Point Arc", "S_V", bpy.ops.bim.cad_arc_from_3_points.__doc__, ui_context)
+ add_layout_hotkey_operator(
+ row,
+ "2-Point Arc",
+ "S_C",
+ bpy.ops.bim.cad_arc_from_2_points.__doc__.split("\n", 1)[1].strip(),
+ ui_context,
+ )
+ row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
+ add_layout_hotkey_operator(
+ row,
+ "3-Point Arc",
+ "S_V",
+ bpy.ops.bim.cad_arc_from_3_points.__doc__.split("\n", 1)[1].strip(),
+ ui_context,
+ )
class CadHotkey(bpy.types.Operator):
diff --git a/src/bonsai/bonsai/bim/module/clash/operator.py b/src/bonsai/bonsai/bim/module/clash/operator.py
index 94866a4e97..3788130a12 100644
--- a/src/bonsai/bonsai/bim/module/clash/operator.py
+++ b/src/bonsai/bonsai/bim/module/clash/operator.py
@@ -457,9 +457,7 @@ class HideClash(bpy.types.Operator):
def execute(self, context):
ClashDecorator.uninstall()
- for area in context.screen.areas:
- if area.type == "VIEW_3D":
- area.tag_redraw()
+ tool.Blender.update_all_viewports(context)
return {"FINISHED"}
diff --git a/src/bonsai/bonsai/bim/module/cost/ui.py b/src/bonsai/bonsai/bim/module/cost/ui.py
index 73a578171f..f8126d3c6c 100644
--- a/src/bonsai/bonsai/bim/module/cost/ui.py
+++ b/src/bonsai/bonsai/bim/module/cost/ui.py
@@ -26,10 +26,11 @@ from bpy.types import Panel, UIList
import bonsai.bim.helper
import bonsai.bim.module.cost.prop as CostProp
import bonsai.tool as tool
-from bonsai.bim.module.cost.data import CostSchedulesData
+from bonsai.bim.module.cost.data import CostItem, CostSchedulesData
if TYPE_CHECKING:
from bonsai.bim.module.cost.prop import BIMCostProperties, CostItemQuantity
+ from bonsai.bim.prop import StrProperty
class BIM_PT_cost_schedules(Panel):
@@ -398,8 +399,8 @@ class BIM_PT_cost_item_types(Panel):
op = row2.operator("bim.calculate_cost_item_resource_value", text="", icon="DISC")
op.cost_item = cost_item.ifc_definition_id
- rtprops = context.scene.BIMResourceTreeProperties
rprops = tool.Resource.get_resource_props()
+ rtprops = rprops.tree
if rtprops.resources and rprops.active_resource_index < len(rtprops.resources):
if has_quantity_names:
op = row2.operator("bim.assign_cost_item_quantity", text="", icon="PROPERTIES")
@@ -661,9 +662,18 @@ class BIM_UL_cost_items_trait:
split2.alignment = "LEFT"
split2.label(text="Rate")
- def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
+ def draw_item(
+ self,
+ context,
+ layout: bpy.types.UILayout,
+ data: BIMCostProperties,
+ item: CostProp.CostItem,
+ icon,
+ active_data,
+ active_propname,
+ ) -> None:
if item:
- self.props = tool.Cost.get_cost_props()
+ self.props = data
cost_item = CostSchedulesData.data["cost_items"][item.ifc_definition_id]
row = layout.row(align=True)
@@ -694,7 +704,7 @@ class BIM_UL_cost_items_trait:
# TODO: reimplement "bim.copy_cost_item_values" somewhere with better UX
- def draw_parent_operator(self, row, cost_item_id):
+ def draw_parent_operator(self, row: bpy.types.UILayout, cost_item_id: int) -> None:
if self.props.active_cost_item_id:
if self.props.active_cost_item_id != cost_item_id:
op = row.operator("bim.change_parent_cost_item", text="", icon="LINKED", emboss=False).new_parent = (
@@ -703,7 +713,7 @@ class BIM_UL_cost_items_trait:
else:
row.label(text="", icon="BLANK1")
- def draw_hierarchy(self, row, item):
+ def draw_hierarchy(self, row: bpy.types.UILayout, item: CostProp.CostItem) -> None:
for i in range(0, item.level_index):
row.label(text="", icon="BLANK1")
if item.has_children:
@@ -749,7 +759,7 @@ class BIM_UL_cost_items_trait:
else:
row.label(text="-")
- def draw_value_column(self, layout, cost_item):
+ def draw_value_column(self, layout: bpy.types.UILayout, cost_item: CostItem) -> None:
if cost_item["TotalAppliedValue"]:
text = "{0:,.2f}".format(cost_item["TotalAppliedValue"]).replace(",", " ")
if cost_item["UnitBasisValueComponent"] not in [None, 1]:
@@ -760,13 +770,13 @@ class BIM_UL_cost_items_trait:
else:
layout.label(text="-")
- def draw_total_cost_column(self, layout, cost_item):
+ def draw_total_cost_column(self, layout: bpy.types.UILayout, cost_item: CostItem) -> None:
format_numbers = "{0:,.2f}".format(cost_item["TotalCost"]).replace(",", " ")
currency = CostSchedulesData.data["currency"]
text = "{} {}".format(format_numbers, currency["name"]) if currency else format_numbers
layout.label(text=text)
- def draw_order_operator(self, row, ifc_definition_id, cost_item):
+ def draw_order_operator(self, row: bpy.types.UILayout, ifc_definition_id: int, cost_item: CostItem) -> None:
if cost_item["NestingIndex"] is not None:
if cost_item["NestingIndex"] == 0:
op = row.operator("bim.reorder_cost_item_nesting", icon="TRIA_DOWN", text="")
@@ -793,12 +803,14 @@ class BIM_UL_cost_item_rates(BIM_UL_cost_items_trait, UIList):
def draw_quantity_column(self, layout, cost_item):
self.draw_uom_column(layout, cost_item)
- def draw_total_cost_column(self, layout, cost_item):
+ def draw_total_cost_column(self, layout: bpy.types.UILayout, cost_item: CostItem) -> None:
pass # No such thing as a total cost in a schedule of rates
class BIM_UL_cost_columns(UIList):
- def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
+ def draw_item(
+ self, context, layout: bpy.types.UILayout, data, item: StrProperty, icon, active_data, active_propname
+ ) -> None:
if item:
row = layout.row(align=True)
row.prop(item, "name", emboss=False, text="")
@@ -806,9 +818,17 @@ class BIM_UL_cost_columns(UIList):
class BIM_UL_cost_item_types(UIList):
- def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
- props = tool.Cost.get_cost_props()
- cost_item = props.cost_items[props.active_cost_item_index]
+ def draw_item(
+ self,
+ context,
+ layout: bpy.types.UILayout,
+ data: BIMCostProperties,
+ item: CostProp.CostItemType,
+ icon,
+ active_data,
+ active_propname,
+ ) -> None:
+ cost_item = data.cost_items[data.active_cost_item_index]
if item:
row = layout.row(align=True)
@@ -844,7 +864,16 @@ class BIM_UL_cost_item_quantities(UIList):
class BIM_UL_product_cost_items(UIList):
- def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
+ def draw_item(
+ self,
+ context,
+ layout: bpy.types.UILayout,
+ data,
+ item: CostItemQuantity,
+ icon,
+ active_data,
+ active_propname,
+ ) -> None:
if item:
row = layout.row(align=True)
op = row.operator("bim.highlight_product_cost_item", text="", icon="STYLUS_PRESSURE")
diff --git a/src/bonsai/bonsai/bim/module/diff/operator.py b/src/bonsai/bonsai/bim/module/diff/operator.py
index 330deed410..020c0c40f2 100644
--- a/src/bonsai/bonsai/bim/module/diff/operator.py
+++ b/src/bonsai/bonsai/bim/module/diff/operator.py
@@ -98,8 +98,8 @@ class VisualiseDiff(bpy.types.Operator):
obj.color = (0.0, 1.0, 0.0, 1.0)
elif global_id in diff["changed"]:
obj.color = (0.0, 0.0, 1.0, 1.0)
- area = next(area for area in context.screen.areas if area.type == "VIEW_3D")
- area.spaces[0].shading.color_type = "OBJECT"
+ assert (space := tool.Blender.get_view3d_space())
+ space.shading.color_type = "OBJECT"
return {"FINISHED"}
diff --git a/src/bonsai/bonsai/bim/module/document/operator.py b/src/bonsai/bonsai/bim/module/document/operator.py
index 006ed27957..21abc42895 100644
--- a/src/bonsai/bonsai/bim/module/document/operator.py
+++ b/src/bonsai/bonsai/bim/module/document/operator.py
@@ -19,6 +19,7 @@
import json
import bpy
+import ifcopenshell.util.element
import bonsai.core.document as core
import bonsai.tool as tool
diff --git a/src/bonsai/bonsai/bim/module/document/prop.py b/src/bonsai/bonsai/bim/module/document/prop.py
index c51f7b636c..01ddf82f43 100644
--- a/src/bonsai/bonsai/bim/module/document/prop.py
+++ b/src/bonsai/bonsai/bim/module/document/prop.py
@@ -1,4 +1,4 @@
-from typing import TYPE_CHECKING, Union
+from typing import TYPE_CHECKING, Literal, Union
import bpy
from bpy.props import (
@@ -66,7 +66,7 @@ class Document(PropertyGroup):
tree_depth: int
has_children: bool
is_expanded: bool
- document_type: str
+ document_type: Literal["PROJECT", "INFORMATION", "REFERENCE"]
class DocumentObject(PropertyGroup):
diff --git a/src/bonsai/bonsai/bim/module/document/ui.py b/src/bonsai/bonsai/bim/module/document/ui.py
index d618d6d966..1b25c05c15 100644
--- a/src/bonsai/bonsai/bim/module/document/ui.py
+++ b/src/bonsai/bonsai/bim/module/document/ui.py
@@ -16,9 +16,22 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see .
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import bpy
from bpy.types import Panel, UIList
import bonsai.tool as tool
+
+if TYPE_CHECKING:
+ from bonsai.bim.module.document.prop import (
+ BIMDocumentProperties,
+ Document,
+ DocumentObject,
+ )
+
from bonsai.bim.helper import draw_attributes
from bonsai.bim.module.document.data import DocumentData, ObjectDocumentData
@@ -207,7 +220,16 @@ class BIM_PT_object_documents(Panel):
class BIM_UL_documents(UIList):
- def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
+ def draw_item(
+ self,
+ context,
+ layout: bpy.types.UILayout,
+ data: BIMDocumentProperties,
+ item: Document,
+ icon,
+ active_data,
+ active_propname,
+ ) -> None:
if item:
row = layout.row(align=True)
indent_depth = 0
@@ -252,16 +274,22 @@ class BIM_UL_documents(UIList):
class BIM_UL_document_objects(UIList):
- def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
+ def draw_item(
+ self,
+ context,
+ layout: bpy.types.UILayout,
+ data: BIMDocumentProperties,
+ item: DocumentObject,
+ icon,
+ active_data,
+ active_propname,
+ ) -> None:
if item:
row = layout.row(align=True)
row.prop(item, "name", text="", emboss=False, icon="OBJECT_DATA")
row.operator("bim.select_object", text="", icon="RESTRICT_SELECT_OFF").obj_name = item.name
- props = tool.Document.get_document_props()
- if props.active_document:
- document = props.active_document
-
+ if document := data.active_document:
op = row.operator("bim.unassign_document", text="", icon="X")
op.document = document.ifc_definition_id
op.obj = item.name
diff --git a/src/bonsai/bonsai/bim/module/drawing/data.py b/src/bonsai/bonsai/bim/module/drawing/data.py
index 8373ed29bc..23ee7c1987 100644
--- a/src/bonsai/bonsai/bim/module/drawing/data.py
+++ b/src/bonsai/bonsai/bim/module/drawing/data.py
@@ -22,7 +22,9 @@ from pathlib import Path
from typing import Any, Union
import bpy
+import ifcopenshell.util.classification
import ifcopenshell.util.element
+import ifcopenshell.util.placement
import ifcopenshell.util.unit
from natsort import natsorted
diff --git a/src/bonsai/bonsai/bim/module/drawing/decoration.py b/src/bonsai/bonsai/bim/module/drawing/decoration.py
index f950f7a1bb..b9b4dd40cb 100644
--- a/src/bonsai/bonsai/bim/module/drawing/decoration.py
+++ b/src/bonsai/bonsai/bim/module/drawing/decoration.py
@@ -70,9 +70,9 @@ class profile_consequential:
cls.start_time = None
lines = "\n".join(cls.lines)
print(lines)
- import pyperclip
- pyperclip.copy(lines)
+ assert (wm := bpy.context.window_manager)
+ wm.clipboard = lines
cls.lines = []
@@ -1787,7 +1787,7 @@ class CutDecorator:
# Handle both old float64 and new float32 checksums for version compatibility
rot_checksum_bytes: bytes = eval(DecoratorData.camera_rotation_checksum)
- rot_check = tool.Blender.np_frombuffer_legacy(rot_checksum_bytes, 9)
+ rot_check = tool.Blender.np_frombuffer_legacy(rot_checksum_bytes, 9).reshape(3, 3)
rot_real = tool.Blender.np_array_legacy(obj.matrix_world.to_3x3())
rot_dot = np.dot(rot_check, rot_real.T)
angle_rad = np.arccos(np.clip((np.trace(rot_dot) - 1) / 2, -1, 1))
diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py
index a83bd0a46a..d350cf80ee 100644
--- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py
+++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py
@@ -151,7 +151,7 @@ from bpy_extras.view3d_utils import (
)
from gpu_extras.batch import batch_for_shader
from ifcopenshell.util.unit import si_conversions
-from mathutils import Matrix, Vector
+from mathutils import Matrix, Vector, geometry
from mathutils.geometry import intersect_line_line
from mathutils.kdtree import KDTree
@@ -1243,9 +1243,7 @@ class SnapManager:
@staticmethod
def _redraw_viewport() -> None:
"""Force 3D viewport redraw."""
- for area in bpy.context.screen.areas:
- if area.type == "VIEW_3D":
- area.tag_redraw()
+ tool.Blender.update_all_viewports()
def build_snap_cache(
self, context: bpy.types.Context, active_obj: bpy.types.Object, include_active: bool = False
@@ -1287,7 +1285,7 @@ class SnapManager:
continue
coords = np.empty(vertex_count * 3, dtype=np.float32)
- mesh.vertices.foreach_get("co", coords) # type: ignore[arg-type]
+ mesh.vertices.foreach_get("co", coords)
coords = coords.reshape(-1, 3)
matrix = np.array(obj_eval.matrix_world, dtype=np.float32)
diff --git a/src/bonsai/bonsai/bim/module/drawing/helper.py b/src/bonsai/bonsai/bim/module/drawing/helper.py
index 7dce81359d..d4895410cb 100644
--- a/src/bonsai/bonsai/bim/module/drawing/helper.py
+++ b/src/bonsai/bonsai/bim/module/drawing/helper.py
@@ -456,7 +456,8 @@ def format_distance(
tx_dist = fmt % d_cm
else:
- tx_dist = fmt % value
+ assert f"Unexpected unit_system - '{unit_system}'."
+ # tx_dist = fmt % value
return tx_dist
diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py
index 582c7b062b..45f67b0769 100644
--- a/src/bonsai/bonsai/bim/module/drawing/operator.py
+++ b/src/bonsai/bonsai/bim/module/drawing/operator.py
@@ -42,6 +42,7 @@ import bmesh
import bpy
import ifcopenshell
import ifcopenshell.api.document
+import ifcopenshell.api.geometry
import ifcopenshell.api.pset
import ifcopenshell.api.style
import ifcopenshell.geom
@@ -1425,6 +1426,7 @@ class CreateDrawing(bpy.types.Operator):
"/Pset_.*Common/.Status",
"EPset_Status.Status",
"EPset_Status.UserDefinedStatus",
+ "Material.Name",
]
group = root.find("{http://www.w3.org/2000/svg}g")
@@ -3304,9 +3306,8 @@ class AddTextLiteral(bpy.types.Operator):
attr.data_type = "string"
attr.string_value = literal_attr_values[attr_name]
- box_alignment_mask = [False] * 9
- box_alignment_mask[6] = True # bottom_left box_alignment
- literal_props.box_alignment = box_alignment_mask
+ literal_props.align_vertical = "bottom"
+ literal_props.align_horizontal = "left"
return {"FINISHED"}
@@ -3364,57 +3365,55 @@ class OrderTextLiteralDown(bpy.types.Operator):
return {"FINISHED"}
-# Ifc Operator is unnecessary, because suboperator is handling IFC changes.
-class AssignSelectedObjectAsProduct(bpy.types.Operator):
+class AssignSelectedObjectAsProduct(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.assign_selected_as_product"
bl_label = "Assign Selected Object As Product"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
- if len(context.selected_objects) != 2:
- cls.poll_message_set("2 objects need to be selected")
+ if len(context.selected_objects) < 2:
+ cls.poll_message_set("At least 2 objects need to be selected")
return False
return True
- def execute(self, context):
- assert bpy.context.view_layer
+ def _execute(self, context):
objs = context.selected_objects[:]
- obj1, obj2 = objs
- element1 = tool.Ifc.get_entity(obj1)
- element2 = tool.Ifc.get_entity(obj2)
- assert element1 and element2
+ ifc_objs = [(o, tool.Ifc.get_entity(o)) for o in objs if tool.Ifc.get_entity(o)]
- # Check if at least one object is an IfcAnnotation
- is_annotation1 = element1.is_a("IfcAnnotation")
- is_annotation2 = element2.is_a("IfcAnnotation")
+ annotations = [(o, e) for o, e in ifc_objs if e.is_a("IfcAnnotation")]
+ non_annotations = [(o, e) for o, e in ifc_objs if not e.is_a("IfcAnnotation")]
- if not (is_annotation1 or is_annotation2):
- self.report({"ERROR"}, "At least one of the selected objects must be IfcAnnotation.")
+ if not annotations:
+ self.report({"ERROR"}, "At least one selected object must be an IfcAnnotation.")
return {"CANCELLED"}
- # If both are annotations, use the currently active object as relating product
- if is_annotation1 and is_annotation2:
+ if len(non_annotations) == 1:
+ # One product, one or more annotations — assign all annotations to the product.
+ product = non_annotations[0][1]
+ elif len(non_annotations) == 0 and len(annotations) == 2:
+ # Both objects are annotations — use the non-active one as the relating product.
active_obj = context.active_object
- if active_obj == obj1:
- other_selected_object = obj1
- bpy.context.view_layer.objects.active = obj2
+ if annotations[0][0] == active_obj:
+ annotation_obj, annotation = annotations[0]
+ product = annotations[1][1]
else:
- other_selected_object = obj2
- bpy.context.view_layer.objects.active = obj1
- # If only one is an annotation, make it the active object
- elif is_annotation1:
- other_selected_object = obj2
- bpy.context.view_layer.objects.active = obj1
+ annotation_obj, annotation = annotations[1]
+ product = annotations[0][1]
+ core.edit_assigned_product(tool.Ifc, tool.Drawing, obj=annotation_obj, product=product)
+ tool.Blender.update_viewport()
+ return
else:
- other_selected_object = obj1
- bpy.context.view_layer.objects.active = obj2
+ self.report(
+ {"ERROR"},
+ "Select exactly one product object and one or more IfcAnnotation objects.",
+ )
+ return {"CANCELLED"}
- assert (active_obj := context.active_object)
- props = tool.Drawing.get_object_assigned_product_props(active_obj)
- props.relating_product = other_selected_object
- bpy.ops.bim.edit_assigned_product()
- return {"FINISHED"}
+ for annotation_obj, _ in annotations:
+ core.edit_assigned_product(tool.Ifc, tool.Drawing, obj=annotation_obj, product=product)
+
+ tool.Blender.update_viewport()
class EditAssignedProduct(bpy.types.Operator, tool.Ifc.Operator):
@@ -3886,8 +3885,7 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
image_filepath = Path(tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path))
ifc_file = tool.Ifc.get()
- params = {"check_existing": False}
- image = load_image(abs_path.name, str(abs_path.parent), **params)
+ image = load_image(abs_path.name, str(abs_path.parent), check_existing=False)
mesh = bpy.data.meshes.new(image_filepath.stem)
obj = bpy.data.objects.new(image_filepath.stem, mesh)
@@ -4177,10 +4175,7 @@ class SelectSimilarTextLiteralValue(bpy.types.Operator):
should_select = True
break
elif self.attribute_type == "box_alignment":
- box_alignment_attr = next(
- (attr for attr in literal.attributes if attr.name == "BoxAlignment"), None
- )
- if box_alignment_attr and box_alignment_attr.string_value == self.literal_value:
+ if literal.get_box_alignment() == self.literal_value:
should_select = True
break
diff --git a/src/bonsai/bonsai/bim/module/drawing/prop.py b/src/bonsai/bonsai/bim/module/drawing/prop.py
index cc597a4802..57c182c02e 100644
--- a/src/bonsai/bonsai/bim/module/drawing/prop.py
+++ b/src/bonsai/bonsai/bim/module/drawing/prop.py
@@ -27,7 +27,6 @@ import ifcopenshell.api.pset
import ifcopenshell.util.element
from bpy.props import (
BoolProperty,
- BoolVectorProperty,
CollectionProperty,
EnumProperty,
FloatProperty,
@@ -673,20 +672,6 @@ class BIMCameraProperties(PropertyGroup):
return ortho_scale, aspect_ratio
-DEFAULT_BOX_ALIGNMENT = [False] * 6 + [True] + [False] * 2
-BOX_ALIGNMENT_POSITIONS = [
- "top-left",
- "top-middle",
- "top-right",
- "middle-left",
- "center",
- "middle-right",
- "bottom-left",
- "bottom-middle",
- "bottom-right",
-]
-
-
class ElementValueRow(PropertyGroup):
"""Represents a single element value row with category, key, and formatted value"""
@@ -789,40 +774,38 @@ def get_category_items_with_counts(self, context):
class LiteralProps(PropertyGroup):
- def set_box_alignment(self, new_value):
- markers = new_value.count(True)
- if not markers:
- return
-
- if markers > 1:
- prev_value = self.get("box_alignment", DEFAULT_BOX_ALIGNMENT)
- # looking for the first value changed to positive
- first_changed_value = next((i for i in range(9) if new_value[i] and new_value[i] != prev_value[i]), None)
-
- # if nothing have changed we just keep the previous value
- if first_changed_value is None:
- return
- new_value = [False] * 9
- new_value[first_changed_value] = True
-
- self["box_alignment"] = new_value
- position_string = BOX_ALIGNMENT_POSITIONS[next(i for i in range(9) if new_value[i])]
- self.attributes["BoxAlignment"].set_value(position_string)
-
- def get_box_alignment(self):
- return self.get("box_alignment", DEFAULT_BOX_ALIGNMENT)
-
attributes: CollectionProperty(name="Attributes", type=Attribute)
- box_alignment: BoolVectorProperty(
- name="Box alignment", size=9, set=set_box_alignment, get=get_box_alignment, default=DEFAULT_BOX_ALIGNMENT
- )
ifc_definition_id: IntProperty(name="IFC definition ID", default=0)
+ align_horizontal: EnumProperty(
+ items=[
+ ("left", "Left", "", "ALIGN_LEFT", 0),
+ ("middle", "Middle", "", "ALIGN_CENTER", 1),
+ ("right", "Right", "", "ALIGN_RIGHT", 2),
+ ],
+ default="left",
+ name="Horizontal Alignment",
+ )
+ align_vertical: EnumProperty(
+ items=[
+ ("top", "Top", "", "ALIGN_TOP", 0),
+ ("middle", "Middle", "", "ALIGN_MIDDLE", 1),
+ ("bottom", "Bottom", "", "ALIGN_BOTTOM", 2),
+ ],
+ default="middle",
+ name="Vertical Alignment",
+ )
+
+ def get_box_alignment(self) -> str:
+ alignment = self.align_vertical + "-" + self.align_horizontal
+ if alignment == "middle-middle":
+ alignment = "center"
+ return alignment
def get_literal_edited_data(self) -> dict[str, str]:
text_data = {
"CurrentValue": self.attributes["Literal"].string_value,
"Literal": self.attributes["Literal"].string_value,
- "BoxAlignment": self.attributes["BoxAlignment"].string_value,
+ "BoxAlignment": self.get_box_alignment(),
}
return text_data
@@ -860,12 +843,19 @@ class LiteralProps(PropertyGroup):
if TYPE_CHECKING:
attributes: bpy.types.bpy_prop_collection_idprop[Attribute]
value: str
- box_alignment: tuple[bool, bool, bool, bool, bool, bool, bool, bool, bool]
ifc_definition_id: int
+ align_horizontal: str
+ align_vertical: str
element_value_rows: bpy.types.bpy_prop_collection_idprop[ElementValueRow]
category_for_adding: str
+def update_text_alignment(self, context):
+ for literal_props in self.literals:
+ literal_props.align_horizontal = self.align_horizontal
+ literal_props.align_vertical = self.align_vertical
+
+
class BIMTextProperties(PropertyGroup):
is_editing: BoolProperty(name="Is Editing", default=False)
literals: CollectionProperty(name="Literals", type=LiteralProps)
@@ -899,6 +889,7 @@ class BIMTextProperties(PropertyGroup):
],
default="left",
name="Horizontal Alignment",
+ update=update_text_alignment,
)
align_vertical: EnumProperty(
items=[
@@ -908,6 +899,7 @@ class BIMTextProperties(PropertyGroup):
],
default="middle",
name="Vertical Alignment",
+ update=update_text_alignment,
)
if TYPE_CHECKING:
diff --git a/src/bonsai/bonsai/bim/module/drawing/shaders.py b/src/bonsai/bonsai/bim/module/drawing/shaders.py
index 774002fea3..c050cf0f41 100644
--- a/src/bonsai/bonsai/bim/module/drawing/shaders.py
+++ b/src/bonsai/bonsai/bim/module/drawing/shaders.py
@@ -366,9 +366,6 @@ class BaseLinesShader(BaseShader):
}
"""
- def __init__(self, gap_size=16):
- super().__init__(gap_size=gap_size)
-
def glenable(self):
super().glenable()
diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py
index 1b6a11cd75..c1809995a0 100644
--- a/src/bonsai/bonsai/bim/module/drawing/ui.py
+++ b/src/bonsai/bonsai/bim/module/drawing/ui.py
@@ -781,33 +781,10 @@ class BIM_PT_text(Panel):
if other_attributes:
bonsai.bim.helper.draw_attributes(other_attributes, box)
- row = box.row(align=True)
- cols = [row.column(align=True) for j in range(3)]
- for j in range(9):
- cols[j % 3].prop(
- literal_props,
- "box_alignment",
- text="",
- index=j,
- icon="RADIOBUT_ON" if literal_props.box_alignment[j] else "RADIOBUT_OFF",
- )
-
- col = row.column(align=True)
- alignment_label_row = col.row(align=True)
- alignment_label_row.label(text=" Text box alignment:")
-
- box_alignment_value = (
- literal_props.attributes[
- next(
- (idx for idx, attr in enumerate(literal_props.attributes) if attr.name == "BoxAlignment"),
- -1,
- )
- ].string_value
- if any(attr.name == "BoxAlignment" for attr in literal_props.attributes)
- else "N/A"
- )
-
- col.label(text=f" {box_alignment_value}")
+ row = box.row()
+ row.label(text="Alignment")
+ row.prop(literal_props, "align_horizontal", text="", expand=True)
+ row.prop(literal_props, "align_vertical", text="", expand=True)
def draw(self, context):
obj = context.active_object
@@ -839,7 +816,6 @@ class BIM_PT_text(Panel):
for i, literal_data in enumerate(text_data["Literals"]):
box = self.layout.box()
- box.label(text=f"Literal[{i}]:")
# Combine both approaches: clickable attributes from PR #7292 and display from PR #7106
for attribute in literal_data:
diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py
index f8389cb3af..7382b093f4 100644
--- a/src/bonsai/bonsai/bim/module/geometry/operator.py
+++ b/src/bonsai/bonsai/bim/module/geometry/operator.py
@@ -1066,7 +1066,7 @@ class OverrideOutlinerDelete(bpy.types.Operator, tool.Ifc.Operator):
cls.poll_message_set("Only available from Outliner.")
return False
- def execute(self, context):
+ def execute(self, context): # ty:ignore[override-of-final-method]
if len(getattr(context, "selected_ids", [])) == 0:
return {"FINISHED"}
@@ -2289,7 +2289,7 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator):
elif obj in pprops.clipping_planes_objs:
self.report({"ERROR"}, "Clipping planes cannot be edited")
elif element:
- if not obj.data:
+ if not obj.data or obj.type not in ("MESH", "CURVE"):
self.report({"INFO"}, "No geometry to edit")
elif tool.Geometry.is_locked(element):
self.report({"ERROR"}, lock_error_message(obj.name))
diff --git a/src/bonsai/bonsai/bim/module/geometry/ui.py b/src/bonsai/bonsai/bim/module/geometry/ui.py
index 2af216c7a1..e7912c7a77 100644
--- a/src/bonsai/bonsai/bim/module/geometry/ui.py
+++ b/src/bonsai/bonsai/bim/module/geometry/ui.py
@@ -515,6 +515,8 @@ class BIM_PT_derived_coordinates(Panel):
return context.active_object is not None
def draw(self, context):
+ assert context.active_object
+ props = tool.Model.get_model_props()
if not DerivedCoordinatesData.is_loaded:
DerivedCoordinatesData.load()
@@ -529,10 +531,8 @@ class BIM_PT_derived_coordinates(Panel):
row = self.layout.row(align=True)
row.enabled = False
- area_3d = next((area for area in context.screen.areas if area.type == "VIEW_3D"), None)
- space_3d = next((space for space in area_3d.spaces if space.type == "VIEW_3D"), None)
- if bpy.context.scene.BIMModelProperties.show_bounding_box:
+ if props.show_bounding_box:
for axis, icon, idx in [("X", "STRIP_COLOR_01", 0), ("Y", "STRIP_COLOR_04", 1), ("Z", "STRIP_COLOR_05", 2)]:
row.label(text="", icon=icon)
row.prop(context.active_object, "dimensions", text=axis, index=idx)
diff --git a/src/bonsai/bonsai/bim/module/georeference/prop.py b/src/bonsai/bonsai/bim/module/georeference/prop.py
index 41035398e9..9b1f8d5f4d 100644
--- a/src/bonsai/bonsai/bim/module/georeference/prop.py
+++ b/src/bonsai/bonsai/bim/module/georeference/prop.py
@@ -139,7 +139,9 @@ def update_local_coordinates(self: "BIMGeoreferenceProperties", context: bpy.typ
tool.Georeference.set_coordinates(
"blender",
ifcopenshell.util.geolocation.enh2xyz(
- *local_coordinates,
+ local_coordinates[0],
+ local_coordinates[1],
+ local_coordinates[2],
float(props.blender_offset_x),
float(props.blender_offset_y),
float(props.blender_offset_z),
@@ -162,7 +164,9 @@ def update_map_coordinates(self: "BIMGeoreferenceProperties", context: bpy.types
tool.Georeference.set_coordinates(
"blender",
ifcopenshell.util.geolocation.enh2xyz(
- *local_coordinates,
+ local_coordinates[0],
+ local_coordinates[1],
+ local_coordinates[2],
float(props.blender_offset_x),
float(props.blender_offset_y),
float(props.blender_offset_z),
@@ -267,6 +271,8 @@ class BIMGeoreferenceProperties(PropertyGroup):
x_axis_ordinate: str
x_axis_is_null: bool
+ model_is_georeferenced: bool
+ model_crs: str
model_origin: str
model_origin_si: str
model_project_north: str
diff --git a/src/bonsai/bonsai/bim/module/gis/prop.py b/src/bonsai/bonsai/bim/module/gis/prop.py
index 1e6ae61614..5971fa80a9 100644
--- a/src/bonsai/bonsai/bim/module/gis/prop.py
+++ b/src/bonsai/bonsai/bim/module/gis/prop.py
@@ -16,6 +16,9 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see .
+from typing import TYPE_CHECKING
+
+import bpy
from bpy.props import BoolProperty, CollectionProperty, EnumProperty, StringProperty
from bpy.types import PropertyGroup
@@ -24,7 +27,7 @@ from bonsai.bim.prop import StrProperty
class BIMCityJsonProperties(PropertyGroup):
def get_lods(self, context):
- global LODS_ENUM_ITEMS
+ global LODS_ENUM_ITEMS # ty: ignore[unresolved-global]
LODS_ENUM_ITEMS = [(item.name, "LOD" + item.name, "Level of Detail " + item.name) for item in self.lods]
return LODS_ENUM_ITEMS
@@ -37,3 +40,13 @@ class BIMCityJsonProperties(PropertyGroup):
lod: EnumProperty(name="LOD", description="", items=get_lods, options={"ANIMATABLE"}, default=None)
is_lod_found: BoolProperty(name="Is LOD Found", default=False)
load_after_convert: BoolProperty(name="Load After Converting", default=True)
+
+ if TYPE_CHECKING:
+ input: str
+ output: str
+ name: str
+ split_lod: bool
+ lods: bpy.types.bpy_prop_collection_idprop[StrProperty]
+ lod: str
+ is_lod_found: bool
+ load_after_convert: bool
diff --git a/src/bonsai/bonsai/bim/module/ifcgit/data.py b/src/bonsai/bonsai/bim/module/ifcgit/data.py
index 92da517a1c..7dbd26b5e0 100644
--- a/src/bonsai/bonsai/bim/module/ifcgit/data.py
+++ b/src/bonsai/bonsai/bim/module/ifcgit/data.py
@@ -21,65 +21,71 @@ class IfcGitData:
@classmethod
def load(cls):
+ repo = None
+ if bool(tool.Ifc.get()):
+ path_ifc = tool.Ifc.get_path()
+ if os.path.isfile(path_ifc):
+ repo = tool.IfcGit.repo_from_path(path_ifc)
+
cls.data = {
- "repo": cls.repo(),
- "remotes": cls.remotes(),
- "branch_names": cls.branch_names(),
- "remote_names": cls.remote_names(),
- "remote_urls": cls.remote_urls(),
+ "repo": repo,
+ "remotes": repo.remotes if repo else None,
+ "branch_names": cls.branch_names(repo),
+ "tag_names": cls.tag_names(repo),
+ "remote_names": cls.remote_names(repo),
+ "remote_urls": {r.name: r.url for r in repo.remotes} if repo else {},
"path_ifc": cls.path_ifc(),
"branches_by_hexsha": cls.branches_by_hexsha(),
"tags_by_hexsha": cls.tags_by_hexsha(),
- "name_ifc": cls.name_ifc(),
+ "name_ifc": cls.name_ifc(repo),
"dir_name": cls.dir_name(),
"base_name": cls.base_name(),
- "working_dir": cls.working_dir(),
- "untracked_files": cls.untracked_files(),
- "is_detached": cls.is_detached(),
- "active_branch_name": cls.active_branch_name(),
- "is_dirty": cls.is_dirty(),
- "commit": cls.commit(),
- "current_revision": cls.current_revision(),
+ "working_dir": repo.working_dir if repo else None,
+ "ifc_is_untracked": cls.ifc_is_untracked(repo),
+ "is_detached": repo.head.is_detached if repo else None,
+ "active_branch_name": repo.active_branch.name if repo and not repo.head.is_detached else None,
+ "is_dirty": cls.is_dirty(repo),
+ "current_revision": cls.current_revision(repo),
"git_exe": cls.git_exe(),
"ifcmerge_exe": cls.ifcmerge_exe(),
}
cls.is_loaded = True
@classmethod
- def repo(cls):
- if bool(tool.Ifc.get()):
- path_ifc = tool.Ifc.get_path()
- if os.path.isfile(path_ifc):
- return tool.IfcGit.repo_from_path(path_ifc)
- return None
+ def branch_names(cls, repo):
+ if not repo or not repo.heads:
+ return []
+ names = sorted([b.name for b in repo.branches])
+ if "main" in names:
+ names.remove("main")
+ names = ["main"] + names
+ if repo.remotes:
+ for remote in repo.remotes:
+ for ref in remote.refs:
+ names.append(ref.name)
+ return names
@classmethod
- def remotes(cls):
- if cls.repo():
- return cls.repo().remotes
- return None
+ def tag_names(cls, repo):
+ if not repo:
+ return []
+ return [t.name for t in repo.tags]
@classmethod
- def branch_names(cls):
- return []
-
- @classmethod
- def remote_names(cls):
- return []
-
- @classmethod
- def remote_urls(cls):
- result = {}
- if cls.repo():
- for remote in cls.repo().remotes:
- result[remote.name] = remote.url
- return result
+ def remote_names(cls, repo):
+ if not repo:
+ return []
+ names = sorted([r.name for r in repo.remotes])
+ if "origin" in names:
+ names.remove("origin")
+ names = ["origin"] + names
+ return names
@classmethod
def path_ifc(cls):
path_ifc = tool.Ifc.get_path()
if os.path.isfile(path_ifc):
- return tool.Ifc.get_path()
+ return path_ifc
return None
@classmethod
@@ -88,7 +94,8 @@ class IfcGitData:
if tool.IfcGitRepo.repo.branches:
return tool.IfcGit.branches_by_hexsha(tool.IfcGitRepo.repo)
except AttributeError:
- return {}
+ pass
+ return {}
@classmethod
def tags_by_hexsha(cls):
@@ -97,12 +104,11 @@ class IfcGitData:
return {}
@classmethod
- def name_ifc(cls):
- if bool(tool.Ifc.get()):
+ def name_ifc(cls, repo):
+ if bool(tool.Ifc.get()) and repo:
path_ifc = tool.Ifc.get_path()
- if tool.IfcGitRepo.repo and os.path.isfile(path_ifc):
- working_dir = tool.IfcGitRepo.repo.working_dir
- return os.path.relpath(path_ifc, working_dir)
+ if os.path.isfile(path_ifc):
+ return os.path.relpath(path_ifc, repo.working_dir)
return None
@classmethod
@@ -122,49 +128,28 @@ class IfcGitData:
return None
@classmethod
- def working_dir(cls):
- if cls.repo():
- return cls.repo().working_dir
+ def ifc_is_untracked(cls, repo):
+ """Return True if the IFC file exists in the repo but has not been added to git."""
+ if not repo:
+ return False
+ path_ifc = tool.Ifc.get_path()
+ if not os.path.isfile(path_ifc):
+ return False
+ return not bool(repo.git.ls_files(path_ifc))
@classmethod
- def untracked_files(cls):
- if cls.repo():
- return cls.repo().untracked_files
- return []
-
- @classmethod
- def is_detached(cls):
- if cls.repo():
- return cls.repo().head.is_detached
-
- @classmethod
- def active_branch_name(cls):
- if cls.repo() and not cls.is_detached():
- return cls.repo().active_branch.name
-
- @classmethod
- def is_dirty(cls):
- if cls.repo() and cls.git_exe():
+ def is_dirty(cls, repo):
+ if repo and cls.git_exe():
path_ifc = tool.Ifc.get_path()
if os.path.isfile(path_ifc):
- return cls.repo().is_dirty(path=path_ifc)
+ return repo.is_dirty(path=path_ifc)
return False
@classmethod
- def commit(cls):
+ def current_revision(cls, repo):
props = tool.IfcGit.get_ifcgit_props()
- if cls.repo() and len(props.ifcgit_commits) > 0:
- item = props.ifcgit_commits[props.commit_index]
- try:
- return cls.repo().commit(rev=item.hexsha)
- except ValueError:
- return
-
- @classmethod
- def current_revision(cls):
- props = tool.IfcGit.get_ifcgit_props()
- if cls.repo() and cls.repo().head.is_valid() and len(props.ifcgit_commits) > 0:
- return tool.IfcGitRepo.repo.commit()
+ if repo and repo.head.is_valid() and len(props.ifcgit_commits) > 0:
+ return repo.commit()
@classmethod
def git_exe(cls):
diff --git a/src/bonsai/bonsai/bim/module/ifcgit/operator.py b/src/bonsai/bonsai/bim/module/ifcgit/operator.py
index 829a0a62a2..5f01ef78ea 100644
--- a/src/bonsai/bonsai/bim/module/ifcgit/operator.py
+++ b/src/bonsai/bonsai/bim/module/ifcgit/operator.py
@@ -120,11 +120,11 @@ class CommitChanges(bpy.types.Operator):
if props.commit_message == "":
return False
if repo:
- if props.new_branch_name in [branch.name for branch in repo.branches]:
+ if props.new_branch_name in IfcGitData.data["branch_names"]:
cls.poll_message_set("Branch already exists!")
return False
elif not tool.IfcGit.is_valid_ref_format(props.new_branch_name):
- if repo.head.is_detached:
+ if IfcGitData.data["is_detached"]:
cls.poll_message_set("Branch name is invalid or empty!")
return False
elif props.new_branch_name != "":
@@ -134,10 +134,17 @@ class CommitChanges(bpy.types.Operator):
def execute(self, context):
- repo = IfcGitData.data["repo"]
- core.commit_changes(tool.IfcGit, tool.Ifc, repo)
- core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc)
+ props = tool.IfcGit.get_ifcgit_props()
+ commit_message = props.commit_message
+ new_branch_name = props.new_branch_name
+ core.commit_changes(tool.IfcGit, tool.Ifc, commit_message, new_branch_name)
+ props.new_branch_name = ""
+ props.commit_message = ""
+ core.refresh_revision_list(tool.IfcGit, tool.Ifc)
refresh()
+ IfcGitData.load()
+ if new_branch_name:
+ props.display_branch = new_branch_name
return {"FINISHED"}
@@ -157,7 +164,7 @@ class AddTag(bpy.types.Operator):
repo = IfcGitData.data["repo"]
if repo and (
not tool.IfcGit.is_valid_ref_format(props.new_tag_name)
- or props.new_tag_name in [tag.name for tag in repo.tags]
+ or props.new_tag_name in IfcGitData.data["tag_names"]
):
return False
return True
@@ -165,8 +172,12 @@ class AddTag(bpy.types.Operator):
def execute(self, context):
repo = IfcGitData.data["repo"]
- core.add_tag(tool.IfcGit, repo)
- core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc)
+ props = tool.IfcGit.get_ifcgit_props()
+ item = props.ifcgit_commits[props.commit_index]
+ core.add_tag(tool.IfcGit, repo, item.hexsha, props.new_tag_name, props.new_tag_message)
+ props.new_tag_name = ""
+ props.new_tag_message = ""
+ core.refresh_revision_list(tool.IfcGit, tool.Ifc)
refresh()
return {"FINISHED"}
@@ -183,7 +194,7 @@ class DeleteTag(bpy.types.Operator):
repo = IfcGitData.data["repo"]
core.delete_tag(tool.IfcGit, repo, self.tag_name)
- core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc)
+ core.refresh_revision_list(tool.IfcGit, tool.Ifc)
refresh()
return {"FINISHED"}
@@ -205,8 +216,7 @@ class RefreshGit(bpy.types.Operator):
def execute(self, context):
- repo = IfcGitData.data["repo"]
- core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc)
+ core.refresh_revision_list(tool.IfcGit, tool.Ifc)
refresh()
tool.IfcGit.decolourise()
return {"FINISHED"}
@@ -284,7 +294,7 @@ class Merge(bpy.types.Operator):
def execute(self, context):
- if core.merge_branch(tool.IfcGit, tool.Ifc, self):
+ if core.merge_branch(tool.IfcGit, tool.Ifc, self) is not False:
refresh()
return {"FINISHED"}
else:
@@ -314,9 +324,9 @@ class Fetch(bpy.types.Operator):
def execute(self, context):
props = tool.IfcGit.get_ifcgit_props()
- repo = IfcGitData.data["repo"]
- remote = repo.remotes[props.select_remote]
- remote.fetch()
+ core.fetch(tool.IfcGit, props.select_remote)
+ core.refresh_revision_list(tool.IfcGit, tool.Ifc)
+ refresh()
return {"FINISHED"}
@@ -336,7 +346,7 @@ class AddRemote(bpy.types.Operator):
not repo
or not tool.IfcGit.is_valid_ref_format(props.remote_name)
or not props.remote_url
- or props.remote_name in [remote.name for remote in repo.remotes]
+ or props.remote_name in IfcGitData.data["remote_names"]
):
return False
return True
@@ -344,8 +354,11 @@ class AddRemote(bpy.types.Operator):
def execute(self, context):
repo = IfcGitData.data["repo"]
- core.add_remote(tool.IfcGit, repo)
- core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc)
+ props = tool.IfcGit.get_ifcgit_props()
+ core.add_remote(tool.IfcGit, repo, props.remote_name, props.remote_url)
+ props.remote_name = ""
+ props.remote_url = ""
+ core.refresh_revision_list(tool.IfcGit, tool.Ifc)
refresh()
return {"FINISHED"}
@@ -360,8 +373,19 @@ class DeleteRemote(bpy.types.Operator):
def execute(self, context):
repo = IfcGitData.data["repo"]
- core.delete_remote(tool.IfcGit, repo)
- core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc)
+ props = tool.IfcGit.get_ifcgit_props()
+ remote_name = props.select_remote
+ if props.display_branch.startswith(remote_name + "/"):
+ active = IfcGitData.data["active_branch_name"]
+ if active:
+ props.display_branch = active
+ else:
+ local_branches = [b for b in IfcGitData.data["branch_names"] if "/" not in b]
+ if local_branches:
+ props.display_branch = local_branches[0]
+ core.delete_remote(tool.IfcGit, repo, remote_name)
+ tool.IfcGit.select_first_remote()
+ core.refresh_revision_list(tool.IfcGit, tool.Ifc)
refresh()
return {"FINISHED"}
@@ -375,8 +399,8 @@ class ObjectLog(bpy.types.Operator):
@classmethod
def poll(cls, context):
- if not (obj := context.active_object):
- cls.poll_message_set("No Active Object")
+ if not (obj := context.active_object) or not obj.select_get():
+ cls.poll_message_set("No selected object")
elif not tool.Blender.get_ifc_definition_id(obj):
cls.poll_message_set("Active Object doesn't have an IFC definition")
else:
diff --git a/src/bonsai/bonsai/bim/module/ifcgit/prop.py b/src/bonsai/bonsai/bim/module/ifcgit/prop.py
index cba3ca632c..865cb4eacf 100644
--- a/src/bonsai/bonsai/bim/module/ifcgit/prop.py
+++ b/src/bonsai/bonsai/bim/module/ifcgit/prop.py
@@ -17,28 +17,14 @@ from bonsai.bim.module.ifcgit.data import IfcGitData
def git_branches(self: "IfcGitProperties", context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS:
# NOTE "Python must keep a reference to the strings returned by
# the callback or Blender will misbehave or even crash"
- IfcGitData.data["branch_names"] = sorted([branch.name for branch in IfcGitData.data["repo"].heads])
-
- if "main" in IfcGitData.data["branch_names"]:
- IfcGitData.data["branch_names"].remove("main")
- IfcGitData.data["branch_names"] = ["main"] + IfcGitData.data["branch_names"]
-
- if IfcGitData.data["remotes"]:
- for remote in IfcGitData.data["remotes"]:
- for remote_branch in remote.refs:
- IfcGitData.data["branch_names"].append(remote_branch.name)
-
- return [(myname, myname, myname) for myname in IfcGitData.data["branch_names"]]
+ # Branch list (local + remote, main first) is computed once in IfcGitData.load()
+ IfcGitData.make_sure_is_loaded()
+ return [(name, name, name) for name in IfcGitData.data["branch_names"]]
def git_remotes(self: "IfcGitProperties", context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS:
- IfcGitData.data["remote_names"] = sorted([remote.name for remote in IfcGitData.data["remotes"]])
-
- if "origin" in IfcGitData.data["remote_names"]:
- IfcGitData.data["remote_names"].remove("origin")
- IfcGitData.data["remote_names"] = ["origin"] + IfcGitData.data["remote_names"]
-
- return [(myname, myname, myname) for myname in IfcGitData.data["remote_names"]]
+ IfcGitData.make_sure_is_loaded()
+ return [(name, name, name) for name in IfcGitData.data["remote_names"]]
def update_revlist(self: "IfcGitProperties", context: bpy.types.Context) -> None:
@@ -90,6 +76,7 @@ class IfcGitListItem(PropertyGroup):
name="Commit Message",
default="",
)
+ committed_date: IntProperty(name="Committed Date", default=0)
tags: CollectionProperty(type=IfcGitTag, name="List of revision tags")
if TYPE_CHECKING:
@@ -98,6 +85,7 @@ class IfcGitListItem(PropertyGroup):
author_name: str
author_email: str
message: str
+ committed_date: int
tags: bpy.types.bpy_prop_collection_idprop[IfcGitTag]
diff --git a/src/bonsai/bonsai/bim/module/ifcgit/ui.py b/src/bonsai/bonsai/bim/module/ifcgit/ui.py
index d8b7357e7b..6ac4aceb74 100644
--- a/src/bonsai/bonsai/bim/module/ifcgit/ui.py
+++ b/src/bonsai/bonsai/bim/module/ifcgit/ui.py
@@ -52,7 +52,7 @@ class IFCGIT_PT_panel(bpy.types.Panel):
if IfcGitData.data["repo"] and os.path.exists(IfcGitData.data["repo"].git_dir):
name_ifc = IfcGitData.data["name_ifc"]
row.label(text=IfcGitData.data["working_dir"], icon="SYSTEM")
- if name_ifc in IfcGitData.data["untracked_files"]:
+ if IfcGitData.data["ifc_is_untracked"]:
row.operator(
"ifcgit.addfile",
text="Add '" + name_ifc + "' to repository",
@@ -216,13 +216,7 @@ class COMMIT_UL_List(bpy.types.UIList):
):
current_revision = IfcGitData.data["current_revision"]
-
- # TODO Figure how this "item" can be acesse in "data.py"
- # so it's possible to move the ".commit"
- try:
- commit = IfcGitData.data["repo"].commit(rev=item.hexsha)
- except ValueError:
- return
+ current_hexsha = current_revision.hexsha if current_revision else None
lookup = IfcGitData.data["branches_by_hexsha"]
refs = ""
@@ -236,11 +230,11 @@ class COMMIT_UL_List(bpy.types.UIList):
for tag in lookup[item.hexsha]:
refs += "{" + tag.name + "} "
- if commit == current_revision:
- layout.label(text="[HEAD] " + refs + commit.message.split("\n")[0], icon="DECORATE_KEYFRAME")
+ if item.hexsha == current_hexsha:
+ layout.label(text="[HEAD] " + refs + item.message.split("\n")[0], icon="DECORATE_KEYFRAME")
else:
- layout.label(text=refs + commit.message.split("\n")[0], icon="DECORATE_ANIMATE")
- layout.label(text=time.strftime("%c", time.localtime(commit.committed_date)))
+ layout.label(text=refs + item.message.split("\n")[0], icon="DECORATE_ANIMATE")
+ layout.label(text=time.strftime("%c", time.localtime(item.committed_date)))
def draw_filter(self, context, layout):
diff --git a/src/bonsai/bonsai/bim/module/material/data.py b/src/bonsai/bonsai/bim/module/material/data.py
index 543a44824b..6d9e568087 100644
--- a/src/bonsai/bonsai/bim/module/material/data.py
+++ b/src/bonsai/bonsai/bim/module/material/data.py
@@ -102,7 +102,6 @@ class MaterialsData:
if (style_name := s.Name) is not None
]
results = natsorted(results, key=lambda i: i[1])
- results.insert(0, ("-", "No Surface Style", ""))
return results
@classmethod
diff --git a/src/bonsai/bonsai/bim/module/material/operator.py b/src/bonsai/bonsai/bim/module/material/operator.py
index e0761bf416..7587a7e4ed 100644
--- a/src/bonsai/bonsai/bim/module/material/operator.py
+++ b/src/bonsai/bonsai/bim/module/material/operator.py
@@ -210,14 +210,15 @@ class AssignMaterialToSelected(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.assign_material_to_selected"
bl_label = "Assign Material To Selected"
bl_description = (
- "Assign currently selected material in Materials UI to the selected objects.\n\n"
- "ALT+CLICK to assign material as a usage."
+ "Assign currently selected material in Materials UI to the selected objects.\n"
+ "Occurrences automatically get usages for layer/profile sets.\n\n"
+ "ALT+CLICK to assign without a usage."
)
bl_options = {"REGISTER", "UNDO"}
material: bpy.props.IntProperty(name="Material IFC ID")
- assign_as_usage: bpy.props.BoolProperty(
- name="Assign Material As A Usage",
- default=False,
+ should_auto_assign_usage: bpy.props.BoolProperty(
+ name="Auto Assign Usage",
+ default=True,
options={"SKIP_SAVE"},
)
@@ -230,25 +231,19 @@ class AssignMaterialToSelected(bpy.types.Operator, tool.Ifc.Operator):
def invoke(self, context, event):
if event.type == "LEFTMOUSE" and event.alt:
- material_class = tool.Ifc.get().by_id(self.material).is_a()
- if material_class not in ("IfcMaterialProfileSet", "IfcMaterialLayerSet"):
- self.report({"ERROR"}, f"{material_class} cannot be assigned as a usage.")
- return {"CANCELLED"}
- self.assign_as_usage = True
+ self.should_auto_assign_usage = False
return self.execute(context)
def _execute(self, context):
material = tool.Ifc.get().by_id(self.material)
objects = tool.Blender.get_selected_objects()
- material_type = material.is_a()
- if self.assign_as_usage:
- material_type += "Usage"
core.assign_material(
tool.Ifc,
tool.Material,
- material_type=material_type,
+ material_type=material.is_a(),
objects=objects,
material=material,
+ should_auto_assign_usage=self.should_auto_assign_usage,
)
@@ -722,7 +717,11 @@ class EnableEditingMaterialSetItem(bpy.types.Operator):
self.props.material_set_item_material = str(material_set_item.Material.id())
self.props.material_set_item_attributes.clear()
- bonsai.bim.helper.import_attributes(material_set_item, self.props.material_set_item_attributes)
+ bonsai.bim.helper.import_attributes(
+ material_set_item,
+ self.props.material_set_item_attributes,
+ callback=self.import_attributes_callback,
+ )
if material_set_item.is_a("IfcMaterialProfile"):
if material_set_item.Profile and material_set_item.Profile.ProfileName:
@@ -730,6 +729,29 @@ class EnableEditingMaterialSetItem(bpy.types.Operator):
return {"FINISHED"}
+ def import_attributes_callback(
+ self, name: str, prop: Union["Attribute", None], data: dict[str, Any]
+ ) -> None | Literal[True]:
+ if data["type"] != "IfcMaterialLayer" or name != "IsVentilated" or not prop:
+ return None
+
+ # Keep null semantics unchanged on export, but avoid an empty UI selection.
+ prop.data_type = "enum"
+ prop.special_type = "LOGICAL"
+ prop.enum_items = json.dumps(("TRUE", "FALSE", "UNKNOWN"))
+
+ value = data[name]
+ if value == "UNKNOWN":
+ prop.enum_value = "UNKNOWN"
+ elif value is None:
+ # Keep visible default as FALSE, but preserve null semantics on save.
+ prop.enum_value = "FALSE"
+ prop.is_null = True
+ else:
+ prop.enum_value = "TRUE" if value else "FALSE"
+
+ return True
+
class DisableEditingMaterialSetItem(bpy.types.Operator):
bl_idname = "bim.disable_editing_material_set_item"
diff --git a/src/bonsai/bonsai/bim/module/material/ui.py b/src/bonsai/bonsai/bim/module/material/ui.py
index f743306767..db16755aad 100644
--- a/src/bonsai/bonsai/bim/module/material/ui.py
+++ b/src/bonsai/bonsai/bim/module/material/ui.py
@@ -118,12 +118,17 @@ class BIM_PT_materials(Panel):
row.operator("bim.edit_material", text="Save Material", icon="CHECKMARK").material = ifc_definition_id
row.operator("bim.disable_editing_material", text="", icon="CANCEL")
elif self.props.editing_material_type == "STYLE":
- row = self.layout.row(align=True)
- row.prop(self.props, "contexts", text="")
- prop_with_search(row, self.props, "styles", text="")
- row = self.layout.row(align=True)
- row.operator("bim.edit_material_style", text="Assign Style", icon="CHECKMARK")
- row.operator("bim.disable_editing_material", text="", icon="CANCEL")
+ if MaterialsData.data["styles"]:
+ row = self.layout.row(align=True)
+ row.prop(self.props, "contexts", text="")
+ prop_with_search(row, self.props, "styles", text="")
+ row = self.layout.row(align=True)
+ row.operator("bim.edit_material_style", text="Assign Style", icon="CHECKMARK")
+ row.operator("bim.disable_editing_material", text="", icon="CANCEL")
+ else:
+ row = self.layout.row(align=True)
+ row.label(text="No Styles Found")
+ row.operator("bim.disable_editing_material", text="", icon="CANCEL")
class BIM_PT_object_material(Panel):
diff --git a/src/bonsai/bonsai/bim/module/misc/__init__.py b/src/bonsai/bonsai/bim/module/misc/__init__.py
index a71e467b7f..c7e2e690d8 100644
--- a/src/bonsai/bonsai/bim/module/misc/__init__.py
+++ b/src/bonsai/bonsai/bim/module/misc/__init__.py
@@ -21,6 +21,11 @@ import bpy
from . import operator, prop, ui
classes = (
+ operator.ImportQuickFavorites,
+ operator.RemoveQuickFavoritesItem,
+ operator.MoveQuickFavoritesItem,
+ operator.AddQuickFavoritesItem,
+ operator.ConfirmQuickFavoriteOperator,
operator.DrawSystemArrows,
operator.GetConnectedSystemElements,
operator.IfcSverchokUseBonsaiFile,
@@ -28,8 +33,12 @@ classes = (
operator.SetOverrideColour,
operator.SnapSpacesTogether,
operator.SplitAlongEdge,
+ prop.QuickFavoriteEnumItem,
+ prop.QuickFavoriteProperty,
+ prop.QuickFavoritesItem,
prop.BIMMiscProperties,
ui.BIM_PT_misc_utilities,
+ ui.BIM_PT_quick_favorites_manager,
)
diff --git a/src/bonsai/bonsai/bim/module/misc/data.py b/src/bonsai/bonsai/bim/module/misc/data.py
new file mode 100644
index 0000000000..eb697d0bff
--- /dev/null
+++ b/src/bonsai/bonsai/bim/module/misc/data.py
@@ -0,0 +1,48 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2021 Dion Moult
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+
+from typing import Any
+
+import bpy
+
+
+def refresh() -> None:
+ QuickFavoritesData.is_loaded = False
+
+
+class QuickFavoritesData:
+ data: dict[str, Any] = {}
+ is_loaded = False
+
+ @classmethod
+ def load(cls) -> None:
+ cls.data = {
+ "operators": cls.operators(),
+ }
+ cls.is_loaded = True
+
+ @classmethod
+ def operators(cls) -> list[str]:
+ items: list[str] = []
+ for module_name in dir(bpy.ops):
+ module = getattr(bpy.ops, module_name)
+ for op_name in dir(module):
+ op = getattr(module, op_name)
+ bl_label = op.get_rna_type().name
+ items.append(f"{module_name}.{op_name} - {bl_label}")
+ return items
diff --git a/src/bonsai/bonsai/bim/module/misc/operator.py b/src/bonsai/bonsai/bim/module/misc/operator.py
index 02cc56834b..33da9a05dc 100644
--- a/src/bonsai/bonsai/bim/module/misc/operator.py
+++ b/src/bonsai/bonsai/bim/module/misc/operator.py
@@ -30,6 +30,9 @@ import bonsai.core.misc as core
import bonsai.core.root
import bonsai.tool as tool
+if TYPE_CHECKING:
+ from bpy.stub_internal import rna_enums
+
class SetOverrideColour(bpy.types.Operator):
bl_idname = "bim.set_override_colour"
@@ -41,10 +44,11 @@ class SetOverrideColour(bpy.types.Operator):
return context.selected_objects
def execute(self, context):
+ props = tool.Misc.get_misc_props()
for obj in context.selected_objects:
- obj.color = context.scene.BIMMiscProperties.override_colour
- area = next(area for area in context.screen.areas if area.type == "VIEW_3D")
- area.spaces[0].shading.color_type = "OBJECT"
+ obj.color = props.override_colour
+ assert (space := tool.Blender.get_view3d_space())
+ space.shading.color_type = "OBJECT"
return {"FINISHED"}
@@ -351,6 +355,151 @@ class DrawSystemArrows(bpy.types.Operator, tool.Ifc.Operator):
return matrix
+class ConfirmQuickFavoriteOperator(bpy.types.Operator):
+ bl_idname = "bim.confirm_quick_favorite_operator"
+ bl_label = "Confirm Operator"
+ bl_options = {"REGISTER", "UNDO"}
+ index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
+
+ if TYPE_CHECKING:
+ index: int
+
+ def execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
+ props = tool.Misc.get_misc_props()
+ fav = props.quick_favorites[self.index]
+ rna = fav.get_searched_operator()
+
+ if rna is None:
+ self.report({"INFO"}, "No operator entered for search.")
+ return {"CANCELLED"}
+
+ fav.operator_id = tool.Blender.operator_idname_to_py(rna.identifier)
+ fav.label = rna.name
+ fav.properties.clear()
+ has_skipped = False
+ for p in rna.properties:
+ # skip silently, e.g. `rna_type` is a PointerProperty
+ if isinstance(p, bpy.types.PointerProperty):
+ continue
+ if isinstance(p, (bpy.types.FloatProperty, bpy.types.BoolProperty, bpy.types.IntProperty)) and p.is_array:
+ print(f"Array property '{p.identifier}' is not supported, skipping.")
+ has_skipped = True
+ continue
+ item = fav.properties.add()
+ item.name = p.identifier
+ item.display_name = p.name
+ if isinstance(p, bpy.types.FloatProperty):
+ item.value_prop = "float_value"
+ item.float_value = p.default
+ elif isinstance(p, bpy.types.BoolProperty):
+ item.value_prop = "bool_value"
+ item.bool_value = p.default
+ elif isinstance(p, bpy.types.IntProperty):
+ item.value_prop = "int_value"
+ item.int_value = p.default
+ elif isinstance(p, bpy.types.EnumProperty):
+ item.value_prop = "enum_value"
+ item.set_enum_items([(e.identifier, e.name, e.description) for e in p.enum_items])
+ item.enum_value = p.default
+ elif isinstance(p, bpy.types.StringProperty):
+ item.value_prop = "string_value"
+ item.string_value = p.default
+ else:
+ print(f"Unhandled property type {type(p).__name__} for '{p.identifier}', skipping.")
+ has_skipped = True
+ if has_skipped:
+ self.report({"WARNING"}, "Some properties were skipped, see the system console for details.")
+ return {"FINISHED"}
+
+
+class ImportQuickFavorites(bpy.types.Operator):
+ bl_idname = "bim.import_quick_favorites"
+ bl_label = "Import Quick Favorites"
+ bl_description = "Import operators from Blender's Quick Favorites menu, including their configured properties"
+ bl_options = {"REGISTER", "UNDO"}
+
+ def execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
+ props = tool.Misc.get_misc_props()
+ props.quick_favorites.clear()
+
+ has_missing_props = False
+ for i, qf in enumerate(tool.Misc.QuickFavorites.get_quick_favorites()):
+ fav = props.quick_favorites.add()
+ fav.label = qf.ui_name
+ fav.search = qf.op_idname_py
+ bpy.ops.bim.confirm_quick_favorite_operator(index=i)
+ fav.label = qf.ui_name or fav.label
+
+ for prop in fav.properties:
+ prop.is_active = prop.name in qf.props
+
+ for key, value in qf.props.items():
+ if key not in fav.properties:
+ print(f"Property '{key}' not found in operator '{qf.op_idname_py}'.")
+ has_missing_props = True
+ continue
+ item = fav.properties[key]
+ item.set_value(value)
+
+ if has_missing_props:
+ self.report(
+ {"WARNING"}, "Some properties were not found during import, see the system console for details."
+ )
+ return {"FINISHED"}
+
+
+class MoveQuickFavoritesItem(bpy.types.Operator):
+ bl_idname = "bim.move_quick_favorites_item"
+ bl_label = "Move Quick Favorites Item"
+ bl_options = {"REGISTER", "UNDO"}
+ index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
+ direction: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
+ items=[("UP", "Up", ""), ("DOWN", "Down", "")]
+ )
+
+ if TYPE_CHECKING:
+ index: int
+ direction: Literal["UP", "DOWN"]
+
+ def execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
+ props = tool.Misc.get_misc_props()
+ total = len(props.quick_favorites)
+ new_index = self.index - 1 if self.direction == "UP" else self.index + 1
+ if 0 <= new_index < total:
+ props.quick_favorites.move(self.index, new_index)
+ return {"FINISHED"}
+
+
+class RemoveQuickFavoritesItem(bpy.types.Operator):
+ bl_idname = "bim.remove_quick_favorites_item"
+ bl_label = "Remove Quick Favorites Item"
+ bl_options = {"REGISTER", "UNDO"}
+ index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
+
+ if TYPE_CHECKING:
+ index: int
+
+ def execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
+ props = tool.Misc.get_misc_props()
+ props.quick_favorites.remove(self.index)
+ return {"FINISHED"}
+
+
+class AddQuickFavoritesItem(bpy.types.Operator):
+ bl_idname = "bim.add_quick_favorites_item"
+ bl_label = "Add Quick Favorites Item"
+ bl_options = {"REGISTER", "UNDO"}
+
+ def execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
+ props = tool.Misc.get_misc_props()
+ fav = props.quick_favorites.add()
+ fav.search = "bim.select_query_elements"
+ index = len(props.quick_favorites) - 1
+ bpy.ops.bim.confirm_quick_favorite_operator(index=index)
+ fav.properties["query"].string_value = "IfcWall"
+ return {"FINISHED"}
+
+
class IfcSverchokUseBonsaiFile(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.ifcsverchok_use_bonsai_file"
bl_label = "Use Bonsai IFC File"
diff --git a/src/bonsai/bonsai/bim/module/misc/prop.py b/src/bonsai/bonsai/bim/module/misc/prop.py
index f596f22fc6..ddeda73b88 100644
--- a/src/bonsai/bonsai/bim/module/misc/prop.py
+++ b/src/bonsai/bonsai/bim/module/misc/prop.py
@@ -16,19 +16,140 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see .
+from typing import TYPE_CHECKING, Any, Literal, cast, get_args
+
+import bpy
from bpy.props import (
+ BoolProperty,
+ CollectionProperty,
+ EnumProperty,
+ FloatProperty,
FloatVectorProperty,
IntProperty,
+ StringProperty,
)
from bpy.types import PropertyGroup
+from bonsai.bim.module.misc.data import QuickFavoritesData
+
+QuickFavoriteValueType = Literal["float_value", "bool_value", "int_value", "string_value", "enum_value"]
+
+
+class QuickFavoriteEnumItem(PropertyGroup):
+ name: StringProperty(name="Name", default="") # pyright: ignore[reportRedeclaration]
+ display_name: StringProperty(name="Display Name", default="") # pyright: ignore[reportRedeclaration]
+ description: StringProperty(name="Description", default="") # pyright: ignore[reportRedeclaration]
+
+ if TYPE_CHECKING:
+ name: str
+ display_name: str
+ description: str
+
+
+def get_enum_items(self: "QuickFavoriteProperty", context: bpy.types.Context | None) -> list[tuple[str, str, str]]:
+ return [(item.name, item.display_name, item.description) for item in self.enum_items]
+
+
+class QuickFavoriteProperty(PropertyGroup):
+ name: StringProperty(name="Name", default="") # pyright: ignore[reportRedeclaration]
+ display_name: StringProperty(name="Display Name", default="") # pyright: ignore[reportRedeclaration]
+ value_prop: EnumProperty( # pyright: ignore[reportRedeclaration]
+ name="Value Prop",
+ items=tuple((v, v, "") for v in get_args(QuickFavoriteValueType)),
+ )
+ string_value: StringProperty(name="String Value", default="") # pyright: ignore[reportRedeclaration]
+ float_value: FloatProperty(name="Float Value", default=0.0) # pyright: ignore[reportRedeclaration]
+ int_value: IntProperty(name="Int Value", default=0) # pyright: ignore[reportRedeclaration]
+ bool_value: BoolProperty(name="Bool Value", default=False) # pyright: ignore[reportRedeclaration]
+ enum_value: EnumProperty(name="Enum Value", items=get_enum_items) # pyright: ignore[reportRedeclaration]
+ enum_items: CollectionProperty(type=QuickFavoriteEnumItem) # pyright: ignore[reportRedeclaration]
+ is_active: BoolProperty( # pyright: ignore[reportRedeclaration]
+ name="Is Active",
+ description="Only active properties will be added to the operator when invoked from Quick Favorites",
+ default=False,
+ )
+
+ def set_value(self, value: Any) -> None:
+ setattr(self, self.value_prop, value)
+
+ def set_enum_items(self, items: list[tuple[str, str, str]]) -> None:
+ self.enum_items.clear()
+ for identifier, name, description in items:
+ item = self.enum_items.add()
+ item.name = identifier
+ item.display_name = name
+ item.description = description
+
+ if TYPE_CHECKING:
+ name: str
+ display_name: str
+ value_prop: QuickFavoriteValueType
+ string_value: str
+ float_value: float
+ int_value: int
+ bool_value: bool
+ enum_value: str
+ enum_items: bpy.types.bpy_prop_collection_idprop[QuickFavoriteEnumItem]
+ is_active: bool
+
+
+def get_operator_suggestions(self: "QuickFavoritesItem", context: bpy.types.Context, edit_text: str) -> list[str]:
+ if not QuickFavoritesData.is_loaded:
+ QuickFavoritesData.load()
+ return QuickFavoritesData.data["operators"]
+
+
+class QuickFavoritesItem(PropertyGroup):
+ is_expanded: BoolProperty(name="Is Expanded", default=False) # pyright: ignore[reportRedeclaration]
+ search: StringProperty( # pyright: ignore[reportRedeclaration]
+ name="Search",
+ default="",
+ search=get_operator_suggestions,
+ # Resetting `search_options`, allowing users only to use suggestions.
+ search_options=set(),
+ )
+ properties: CollectionProperty(type=QuickFavoriteProperty) # pyright: ignore[reportRedeclaration]
+ operator_id: StringProperty( # pyright: ignore[reportRedeclaration]
+ name="Operator ID",
+ default="",
+ )
+ label: StringProperty( # pyright: ignore[reportRedeclaration]
+ name="Label",
+ description="Label that will be used in Quick Favorites for this operator",
+ default="",
+ )
+
+ def get_searched_operator(self) -> bpy.types.Struct | None:
+ if not self.search:
+ return None
+ search_label = self.search
+ name = search_label.split(" - ", 1)[0]
+ module, func = name.split(".", 1)
+ op = getattr(getattr(bpy.ops, module), func)
+ rna = cast(bpy.types.Struct, op.get_rna_type())
+ return rna
+
+ if TYPE_CHECKING:
+ is_expanded: bool
+ search: str
+ """Internal property set when confirming results of the search field"""
+ properties: bpy.types.bpy_prop_collection_idprop[QuickFavoriteProperty]
+ operator_id: str
+ label: str
+
class BIMMiscProperties(PropertyGroup):
- total_storeys: IntProperty(
+ total_storeys: IntProperty( # pyright: ignore[reportRedeclaration]
name="Total Storeys",
description="Number of storeys above object's storey to take into account for resizing",
default=1,
)
- override_colour: FloatVectorProperty(
+ override_colour: FloatVectorProperty( # pyright: ignore[reportRedeclaration]
name="Override Colour", subtype="COLOR", default=(1, 0, 0, 1), min=0.0, max=1.0, size=4
)
+ quick_favorites: CollectionProperty(type=QuickFavoritesItem) # pyright: ignore[reportRedeclaration]
+
+ if TYPE_CHECKING:
+ total_storeys: int
+ override_colour: tuple[float, float, float, float]
+ quick_favorites: bpy.types.bpy_prop_collection_idprop[QuickFavoritesItem]
diff --git a/src/bonsai/bonsai/bim/module/misc/ui.py b/src/bonsai/bonsai/bim/module/misc/ui.py
index c009b720e4..3e4a952bc3 100644
--- a/src/bonsai/bonsai/bim/module/misc/ui.py
+++ b/src/bonsai/bonsai/bim/module/misc/ui.py
@@ -18,6 +18,8 @@
import bpy
+import bonsai.tool as tool
+
class BIM_PT_misc_utilities(bpy.types.Panel):
bl_idname = "BIM_PT_misc_utilities"
@@ -30,7 +32,8 @@ class BIM_PT_misc_utilities(bpy.types.Panel):
def draw(self, context):
layout = self.layout
- props = context.scene.BIMMiscProperties
+ assert layout
+ props = tool.Misc.get_misc_props()
row = layout.split(factor=0.2, align=True)
row.prop(props, "override_colour", text="")
row.operator("bim.set_override_colour")
@@ -56,3 +59,72 @@ class BIM_PT_misc_utilities(bpy.types.Panel):
row.operator("bim.disable_editing_sketch_extrusion_profile", text="", icon="CANCEL")
row = layout.row()
row.operator("bim.import_plot", text="Import Plot Coordinates", icon="FILE_FOLDER")
+
+
+class BIM_PT_quick_favorites_manager(bpy.types.Panel):
+ bl_idname = "BIM_PT_quick_favorites_manager"
+ bl_label = "Quick Favorites Manager"
+ bl_space_type = "PROPERTIES"
+ bl_region_type = "WINDOW"
+ bl_context = "output"
+ bl_options = {"DEFAULT_CLOSED"}
+ bl_parent_id = "BIM_PT_tab_sandbox"
+
+ def draw(self, context):
+ layout = self.layout
+ assert layout
+ props = tool.Misc.get_misc_props()
+
+ row = layout.row(align=True)
+ row.label(text="Quick Favorites:")
+ row.operator("bim.add_quick_favorites_item", text="", icon="ADD")
+ row.operator("bim.import_quick_favorites", text="", icon="BLENDER")
+ op = row.operator("bim.show_description", text="", icon="INFO")
+ op.attr_name = "Quick Favorites Manager"
+ op.description = (
+ "Blender does not support editing Quick Favorites natively. "
+ "This manager allows you to load existing Quick Favorites operators, "
+ "configure their properties and labels, and re-add them to the menu with customized settings."
+ )
+
+ for fav in props.quick_favorites:
+ if fav.operator_id:
+ row = layout.row()
+ op = row.operator(fav.operator_id, text=fav.label)
+ for item in fav.properties:
+ if item.is_active:
+ setattr(op, item.name, getattr(item, item.value_prop))
+
+ layout.separator()
+
+ for i, fav in enumerate(props.quick_favorites):
+ box = layout.box()
+ row = box.row(align=True)
+ row.prop(fav, "is_expanded", text="", icon="TRIA_DOWN" if fav.is_expanded else "TRIA_RIGHT", emboss=False)
+ row.prop(fav, "label", text="")
+ if i > 0:
+ up = row.operator("bim.move_quick_favorites_item", text="", icon="TRIA_UP")
+ up.index = i
+ up.direction = "UP"
+ if i < len(props.quick_favorites) - 1:
+ down = row.operator("bim.move_quick_favorites_item", text="", icon="TRIA_DOWN")
+ down.index = i
+ down.direction = "DOWN"
+ row.operator("bim.remove_quick_favorites_item", text="", icon="X").index = i
+ if not fav.is_expanded:
+ continue
+ row = box.row(align=True)
+ row.prop(fav, "search", text="")
+ row.operator("bim.confirm_quick_favorite_operator", text="", icon="VIEWZOOM").index = i
+ if not fav.operator_id:
+ continue
+ layout.separator()
+ if fav.properties:
+ box.label(text="Properties:")
+ prop_box = box.box()
+ for item in fav.properties:
+ row = prop_box.row(align=True)
+ row.prop(item, item.value_prop, text=item.display_name)
+ row.prop(item, "is_active", text="", icon="RADIOBUT_ON" if item.is_active else "RADIOBUT_OFF")
+ else:
+ box.label(text="No Properties.")
diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py
index d4dc1a218d..49090e2c9f 100644
--- a/src/bonsai/bonsai/bim/module/model/decorator.py
+++ b/src/bonsai/bonsai/bim/module/model/decorator.py
@@ -19,7 +19,7 @@
from __future__ import annotations
import math
-from math import cos, radians, sin, tan
+from math import cos, pi, radians, sin, tan
from typing import Any, Literal
import blf
@@ -27,6 +27,10 @@ import bmesh
import bpy
import gpu
import ifcopenshell
+import ifcopenshell.geom
+import ifcopenshell.util.element
+import ifcopenshell.util.representation
+import ifcopenshell.util.unit
import mathutils
from bpy.types import SpaceView3D
from bpy_extras import view3d_utils
@@ -35,6 +39,7 @@ from gpu_extras.batch import batch_for_shader
from gpu_extras.presets import draw_circle_2d
from mathutils import Matrix, Quaternion, Vector
+import bonsai.core.geometry
import bonsai.tool as tool
from bonsai.bim.module.drawing.helper import format_distance
@@ -1566,7 +1571,7 @@ class ProductDecorator:
obj_type,
representation,
)
- context.view_layer.update()
+ bpy.context.view_layer.update()
break
translate_mouse = Matrix.Translation(mouse_point)
diff --git a/src/bonsai/bonsai/bim/module/model/grid.py b/src/bonsai/bonsai/bim/module/model/grid.py
index 8ceaa605dc..dd4e919e30 100644
--- a/src/bonsai/bonsai/bim/module/model/grid.py
+++ b/src/bonsai/bonsai/bim/module/model/grid.py
@@ -82,7 +82,7 @@ def add_object(self: "BIM_OT_add_object", context: bpy.types.Context) -> None:
class BIM_OT_add_object(Operator, tool.Ifc.Operator):
- bl_idname = "mesh.add_grid"
+ bl_idname = "bim.add_grid"
bl_label = "Grid"
bl_description = "Add IfcGrid."
bl_options = {"REGISTER", "UNDO"}
diff --git a/src/bonsai/bonsai/bim/module/model/mep.py b/src/bonsai/bonsai/bim/module/model/mep.py
index 245e10a21b..df34166229 100644
--- a/src/bonsai/bonsai/bim/module/model/mep.py
+++ b/src/bonsai/bonsai/bim/module/model/mep.py
@@ -227,7 +227,7 @@ class FitFlowSegments(bpy.types.Operator, tool.Ifc.Operator):
is_parallel21 = tool.Cad.is_x(angle21, (0, 180), tolerance=0.001)
is_parallel23 = tool.Cad.is_x(angle23, (0, 180), tolerance=0.001)
- if not all(is_parallel12, is_parallel13, is_parallel21, is_parallel23):
+ if not all([is_parallel12, is_parallel13, is_parallel21, is_parallel23]):
fitting_type = "WYE"
if not fitting_type:
@@ -903,7 +903,7 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator):
start_segment_id: bpy.props.IntProperty(name="Start Segment Element ID", default=0)
end_segment_id: bpy.props.IntProperty(name="End Segment Element ID", default=0)
radius: bpy.props.FloatProperty(
- "Bend Inner Radius", description="Bend inner radius in SI units", default=0.2, subtype="DISTANCE", min=0
+ name="Bend Inner Radius", description="Bend inner radius in SI units", default=0.2, subtype="DISTANCE", min=0
)
def _execute(self, context):
diff --git a/src/bonsai/bonsai/bim/module/model/opening.py b/src/bonsai/bonsai/bim/module/model/opening.py
index c20325c2e7..1c157afe4e 100644
--- a/src/bonsai/bonsai/bim/module/model/opening.py
+++ b/src/bonsai/bonsai/bim/module/model/opening.py
@@ -151,29 +151,11 @@ class FilledOpeningGenerator:
existing_opening_occurrence, "Model", "Body", "MODEL_VIEW"
)
assert representation
-
- # Check if mapped representation - PRESERVE the mapping structure
- if (
- representation.RepresentationType == "MappedRepresentation"
- and len(representation.Items) == 1
- and representation.Items[0].is_a("IfcMappedItem")
- ):
- # Store the existing RepresentationMap to reuse it
- existing_mapping_source = representation.Items[0].MappingSource
- reuse_mapped_representation = True
- else:
- representation = ifcopenshell.util.representation.resolve_representation(representation)
-
- if not reuse_mapped_representation:
- # Check for library template before generating from filling
- template_rep = self.get_opening_template_from_type(filling)
-
- if template_rep:
- representation = template_rep
- else:
- representation = self.generate_opening_from_filling(
- filling, filling_obj, opening_thickness_si=opening_thickness_si
- )
+ representation = ifcopenshell.util.representation.resolve_representation(representation)
+ else:
+ representation = self.generate_opening_from_filling(
+ filling, filling_obj, opening_thickness_si=opening_thickness_si
+ )
# Create mapped representation
if reuse_mapped_representation:
@@ -247,109 +229,38 @@ class FilledOpeningGenerator:
voided_element = opening.VoidsElements[0].RelatingBuildingElement
opening_rep = ifcopenshell.util.representation.get_representation(opening, "Model", "Body", "MODEL_VIEW")
-
- # ALWAYS preserve the existing opening representation (Tessellation, SweptSolid, etc.)
- preserved_representation = None
- if opening_rep:
- if (
- opening_rep.RepresentationType == "MappedRepresentation"
- and len(opening_rep.Items) == 1
- and opening_rep.Items[0].is_a("IfcMappedItem")
- ):
- # For mapped representations, copy the underlying representation
- preserved_representation = ifcopenshell.util.element.copy_deep(
- tool.Ifc.get(),
- opening_rep.Items[0].MappingSource.MappedRepresentation,
- exclude=["IfcGeometricRepresentationContext"],
- )
- else:
- # For direct representations (non-mapped), copy them too
- preserved_representation = ifcopenshell.util.element.copy_deep(
- tool.Ifc.get(), opening_rep, exclude=["IfcGeometricRepresentationContext"]
- )
-
ifcopenshell.api.geometry.unassign_representation(tool.Ifc.get(), product=opening, representation=opening_rep)
ifcopenshell.api.geometry.remove_representation(tool.Ifc.get(), representation=opening_rep)
existing_opening_occurrence = self.get_existing_opening_occurrence_if_any(filling)
- # Priority order for choosing representation:
- # 1. Existing occurrence with MappedRepresentation (preserve mapping!)
- # 2. Library template with Tessellation
- # 3. Preserved representation from old opening (maintain user's work)
- # 4. Generate from filling (last resort)
-
- representation_to_use = None
- reuse_mapped_representation = False
- existing_mapping_source = None
-
if existing_opening_occurrence:
representation = ifcopenshell.util.representation.get_representation(
existing_opening_occurrence, "Model", "Body", "MODEL_VIEW"
)
-
- if (
- representation
- and representation.RepresentationType == "MappedRepresentation"
- and len(representation.Items) == 1
- and representation.Items[0].is_a("IfcMappedItem")
- ):
- # PRESERVE the mapped structure - reuse the same RepresentationMap
- existing_mapping_source = representation.Items[0].MappingSource
- reuse_mapped_representation = True
- else:
- representation_to_use = ifcopenshell.util.representation.resolve_representation(representation)
-
- if not representation_to_use and not reuse_mapped_representation:
- template_rep = self.get_opening_template_from_type(filling)
- if template_rep and template_rep.RepresentationType == "Tessellation":
- representation_to_use = template_rep
-
- if not representation_to_use and not reuse_mapped_representation and preserved_representation:
- representation_to_use = preserved_representation
-
- if not representation_to_use and not reuse_mapped_representation:
+ representation = ifcopenshell.util.representation.resolve_representation(representation)
+ mapped_representation = ifcopenshell.api.geometry.map_representation(
+ tool.Ifc.get(), representation=representation
+ )
+ ifcopenshell.api.geometry.assign_representation(
+ tool.Ifc.get(), product=opening, representation=mapped_representation
+ )
+ else:
opening_obj = tool.Ifc.get_object(opening)
if opening_obj:
tool.Ifc.unlink(element=opening)
tool.Blender.remove_data_blocks([opening_obj], remove_unused_data=True)
filling_obj = tool.Ifc.get_object(filling)
- representation_to_use = self.generate_opening_from_filling(filling, filling_obj)
-
- # Create the mapped representation
- if reuse_mapped_representation:
- # Reuse existing RepresentationMap - don't create a new one!
- context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW")
- new_mapped_item = tool.Ifc.get().create_entity(
- "IfcMappedItem",
- MappingSource=existing_mapping_source,
- MappingTarget=tool.Ifc.get().create_entity(
- "IfcCartesianTransformationOperator3D",
- Axis1=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)),
- Axis2=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0.0, 1.0, 0.0)),
- LocalOrigin=tool.Ifc.get().create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)),
- Scale=1.0,
- Axis3=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)),
- ),
- )
- mapped_representation = tool.Ifc.get().create_entity(
- "IfcShapeRepresentation",
- ContextOfItems=context,
- RepresentationIdentifier="Body",
- RepresentationType="MappedRepresentation",
- Items=[new_mapped_item],
- )
- else:
+ representation = self.generate_opening_from_filling(filling, filling_obj)
mapped_representation = ifcopenshell.api.geometry.map_representation(
- tool.Ifc.get(), representation=representation_to_use
+ tool.Ifc.get(), representation=representation
+ )
+ ifcopenshell.api.geometry.assign_representation(
+ tool.Ifc.get(), product=opening, representation=mapped_representation
)
- ifcopenshell.api.geometry.assign_representation(
- tool.Ifc.get(), product=opening, representation=mapped_representation
- )
-
- # update voided object representation...
+ # update voided object representation or all it's parts if it's an aggregate
voided_elements = ifcopenshell.util.element.get_parts(voided_element) or [voided_element]
for voided_element in voided_elements:
voided_obj = tool.Ifc.get_object(voided_element)
@@ -363,36 +274,6 @@ class FilledOpeningGenerator:
representation=representation,
)
- def get_opening_template_from_type(
- self, filling: ifcopenshell.entity_instance
- ) -> Union[ifcopenshell.entity_instance, None]:
- """
- Check if the filling's type has a stored opening template from library import.
- """
- element_type = ifcopenshell.util.element.get_type(filling)
-
- if not element_type:
- return None
-
- desc = element_type.Description
-
- if not desc or "||BonsaiOpeningTemplate:" not in desc:
- return None
-
- # Extract template ID
- marker = desc.split("||BonsaiOpeningTemplate:")[-1]
- template_id = int(marker.split("||")[0])
-
- try:
- template_rep = tool.Ifc.get().by_id(template_id)
- # Make a copy so we don't reuse the same representation instance
- copied = ifcopenshell.util.element.copy_deep(
- tool.Ifc.get(), template_rep, exclude=["IfcGeometricRepresentationContext"]
- )
- return copied
- except:
- return None
-
def generate_opening_from_filling(
self,
filling: ifcopenshell.entity_instance,
@@ -659,6 +540,16 @@ class AddBoolean(Operator, tool.Ifc.Operator):
booleans = ifcopenshell.api.geometry.add_boolean(tool.Ifc.get(), first_item, second_items, props.operator)
rep_obj = tool.Geometry.get_geometry_props().representation_obj
+ if booleans:
+ # Users typically select two top-level items and expect the
+ # operand to be absorbed into the boolean, not remain as a
+ # standalone item alongside it.
+ representation = tool.Geometry.get_active_representation(rep_obj)
+ representation = ifcopenshell.util.representation.resolve_representation(representation)
+ second_items_set = set(second_items)
+ new_items = [i for i in representation.Items if i not in second_items_set]
+ if new_items:
+ representation.Items = new_items
rep_element = tool.Ifc.get_entity(rep_obj)
tool.Model.mark_manual_booleans(rep_element, booleans)
tool.Geometry.reload_representation(rep_obj)
diff --git a/src/bonsai/bonsai/bim/module/model/polyline.py b/src/bonsai/bonsai/bim/module/model/polyline.py
index e33fe2cf5d..be491e756b 100644
--- a/src/bonsai/bonsai/bim/module/model/polyline.py
+++ b/src/bonsai/bonsai/bim/module/model/polyline.py
@@ -421,7 +421,7 @@ class PolylineOperator:
tool.Polyline.calculate_x_y_and_z(context, self.input_ui, self.tool_state)
tool.Blender.update_viewport()
- return {"RUNNING_MODAL"}
+ return {"RUNNING_MODAL"}
def set_offset(self, context: bpy.types.Context, relating_type: ifcopenshell.entity_instance) -> None:
props = tool.Model.get_model_props()
@@ -461,6 +461,7 @@ class PolylineOperator:
self.tool_state.axis_method = None
self.tool_state.plane_method = None
self.tool_state.mode = "Mouse"
+ 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):
diff --git a/src/bonsai/bonsai/bim/module/model/product.py b/src/bonsai/bonsai/bim/module/model/product.py
index d7c96bce1d..4cf4e00172 100644
--- a/src/bonsai/bonsai/bim/module/model/product.py
+++ b/src/bonsai/bonsai/bim/module/model/product.py
@@ -694,10 +694,14 @@ def generate_box(usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[
new_settings = settings.copy()
new_settings["context"] = box_context
- new_box = ifcopenshell.api.geometry.add_representation(ifc_file, should_run_listeners=False, **new_settings)
+ new_box = ifcopenshell.api.geometry.add_representation(
+ ifc_file,
+ should_run_listeners=False, # ty:ignore[unknown-argument]
+ **new_settings,
+ )
ifcopenshell.api.geometry.assign_representation(
ifc_file,
- should_run_listeners=False,
+ should_run_listeners=False, # ty:ignore[unknown-argument]
product=product,
representation=new_box,
)
diff --git a/src/bonsai/bonsai/bim/module/model/profile.py b/src/bonsai/bonsai/bim/module/model/profile.py
index f759d40a94..1369ad3cb6 100644
--- a/src/bonsai/bonsai/bim/module/model/profile.py
+++ b/src/bonsai/bonsai/bim/module/model/profile.py
@@ -18,7 +18,7 @@
import copy
from math import atan2, degrees, pi, radians
-from typing import Any, Literal, Optional, Union
+from typing import TYPE_CHECKING, Any, Literal, Optional, Union
import bpy
import ifcopenshell
@@ -49,7 +49,7 @@ ProfileFrom2PointsReturn = Union[dict[str, Any], None]
class DumbProfileGenerator:
- def __init__(self, relating_type):
+ def __init__(self, relating_type: ifcopenshell.entity_instance):
self.relating_type = relating_type
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
@@ -201,7 +201,7 @@ class DumbProfileGenerator:
class DumbProfileRegenerator:
- def regenerate_from_profile_def(self, profile):
+ def regenerate_from_profile_def(self, profile: ifcopenshell.entity_instance) -> None:
self.file = tool.Ifc.get()
objs = []
if not profile:
@@ -221,7 +221,7 @@ class DumbProfileRegenerator:
for element in self.get_element_types_using_profile(profile):
tool.Model.mark_thumbnail_for_update(element)
- def regenerate_from_profile(self, usecase_path, ifc_file, settings):
+ def regenerate_from_profile(self, usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[str, Any]) -> None:
self.file = ifc_file
objs = []
profile = settings["profile"].Profile
@@ -233,7 +233,7 @@ class DumbProfileRegenerator:
objs.append(obj)
DumbProfileRecalculator().recalculate(objs)
- def get_elements_using_profile(self, profile):
+ def get_elements_using_profile(self, profile: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
results = []
profile_sets = [
mp.ToMaterialProfileSet[0] for mp in self.file.get_inverse(profile) if mp.is_a("IfcMaterialProfile")
@@ -252,7 +252,9 @@ class DumbProfileRegenerator:
results.extend(rel.RelatedObjects)
return results
- def get_element_types_using_profile(self, profile):
+ def get_element_types_using_profile(
+ self, profile: ifcopenshell.entity_instance
+ ) -> list[ifcopenshell.entity_instance]:
results = []
profile_sets = [
mp.ToMaterialProfileSet[0] for mp in self.file.get_inverse(profile) if mp.is_a("IfcMaterialProfile")
@@ -269,12 +271,18 @@ class ExtendProfile(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.extend_profile"
bl_label = "Extend Profile"
bl_options = {"REGISTER", "UNDO"}
- join_type: bpy.props.StringProperty()
+ join_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
+ items=[("-", "Unjoin", ""), ("L", "L", ""), ("V", "V", ""), ("T", "T", "")],
+ default="-",
+ )
+
+ if TYPE_CHECKING:
+ join_type: Literal["-", "L", "V", "T"]
def _execute(self, context):
selected_objs = context.selected_objects
joiner = DumbProfileJoiner()
- if not self.join_type:
+ if self.join_type == "-":
for obj in selected_objs:
joiner.unjoin(obj)
return {"FINISHED"}
@@ -626,11 +634,15 @@ class DumbProfileJoiner:
if connection1 == "ATEND":
if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal:
plane = self.get_profile_plane(profile2, furthest_plane)
- intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
+ intersect = mathutils.geometry.intersect_line_plane(
+ axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
+ )
self.body[1] = intersect
else:
plane = self.get_profile_plane(profile2, furthest_plane, z_inwards=False)
- intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
+ intersect = mathutils.geometry.intersect_line_plane(
+ axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
+ )
max_dim = self.get_max_bound_box_dimension(profile1)
self.body[1] = intersect + profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim))
@@ -673,11 +685,15 @@ class DumbProfileJoiner:
elif connection1 == "ATSTART":
if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal:
plane = self.get_profile_plane(profile2, furthest_plane)
- intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
+ intersect = mathutils.geometry.intersect_line_plane(
+ axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
+ )
self.body[0] = intersect
else:
plane = self.get_profile_plane(profile2, furthest_plane, z_inwards=False)
- intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
+ intersect = mathutils.geometry.intersect_line_plane(
+ axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
+ )
max_dim = self.get_max_bound_box_dimension(profile1)
self.body[0] = intersect - profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim))
@@ -721,7 +737,9 @@ class DumbProfileJoiner:
if connection1 == "ATEND":
if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal:
plane = self.get_profile_plane(profile2, furthest_plane if is_relating else closest_plane)
- intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
+ intersect = mathutils.geometry.intersect_line_plane(
+ axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
+ )
self.body[1] = intersect
else:
plane = self.get_profile_plane(
@@ -729,7 +747,9 @@ class DumbProfileJoiner:
furthest_plane if is_relating else closest_plane,
z_inwards=False if is_relating else True,
)
- intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
+ intersect = mathutils.geometry.intersect_line_plane(
+ axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
+ )
max_dim = self.get_max_bound_box_dimension(profile1)
self.body[1] = intersect + profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim))
self.clippings.append(
@@ -742,7 +762,9 @@ class DumbProfileJoiner:
elif connection1 == "ATSTART":
if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal:
plane = self.get_profile_plane(profile2, furthest_plane if is_relating else closest_plane)
- intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
+ intersect = mathutils.geometry.intersect_line_plane(
+ axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
+ )
self.body[0] = intersect
else:
plane = self.get_profile_plane(
@@ -750,7 +772,9 @@ class DumbProfileJoiner:
furthest_plane if is_relating else closest_plane,
z_inwards=False if is_relating else True,
)
- intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
+ intersect = mathutils.geometry.intersect_line_plane(
+ axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
+ )
max_dim = self.get_max_bound_box_dimension(profile1)
self.body[0] = intersect - profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim))
self.clippings.append(
diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py
index cb246ac7c5..c4956056aa 100644
--- a/src/bonsai/bonsai/bim/module/model/prop.py
+++ b/src/bonsai/bonsai/bim/module/model/prop.py
@@ -733,6 +733,9 @@ class BIMStairProperties(PropertyGroup):
class BIMSverchokProperties(PropertyGroup):
node_group: bpy.props.PointerProperty(name="Node Group", type=NodeTree)
+ if TYPE_CHECKING:
+ node_group: bpy.types.NodeTree | None
+
def window_type_prop_update(self, context):
number_of_panels, panels_data = self.window_types_panels[self.window_type]
diff --git a/src/bonsai/bonsai/bim/module/model/sverchok_modifier.py b/src/bonsai/bonsai/bim/module/model/sverchok_modifier.py
index 08f29890c4..448fb9725c 100644
--- a/src/bonsai/bonsai/bim/module/model/sverchok_modifier.py
+++ b/src/bonsai/bonsai/bim/module/model/sverchok_modifier.py
@@ -31,7 +31,7 @@ import bonsai.tool as tool
def update_sverchok_modifier(context):
obj = context.active_object
- props = obj.BIMSverchokProperties
+ props = tool.Model.get_sverchok_props(obj)
element = tool.Ifc.get_entity(obj)
psets = ifcopenshell.util.element.get_psets(element)
pset = psets.get("BBIM_Sverchok", None)
@@ -69,10 +69,10 @@ class CreateNewSverchokGraph(bpy.types.Operator, tool.Ifc.Operator):
bl_options = {"REGISTER"}
def _execute(self, context):
- import sverchok
+ import sverchok.ui.sv_temporal_viewers
obj = context.active_object
- props = obj.BIMSverchokProperties
+ props = tool.Model.get_sverchok_props(obj)
node_group = bpy.data.node_groups.new("IfcNodeTree", type="SverchCustomTreeType")
plane = node_group.nodes.new(type="SvPlaneNodeMk3")
@@ -96,7 +96,7 @@ class DeleteSverchokGraph(bpy.types.Operator, tool.Ifc.Operator):
def _execute(self, context):
obj = context.active_object
element = tool.Ifc.get_entity(obj)
- props = obj.BIMSverchokProperties
+ props = tool.Model.get_sverchok_props(obj)
bpy.data.node_groups.remove(props.node_group)
return {"FINISHED"}
@@ -113,7 +113,8 @@ class UpdateDataFromSverchok(bpy.types.Operator, tool.Ifc.Operator):
bl_options = {"REGISTER"}
def invoke(self, context, event):
- if not context.active_object.BIMSverchokProperties.node_group:
+ props = tool.Model.get_sverchok_props(context.active_object)
+ if not props.node_group:
return context.window_manager.invoke_props_dialog(self)
return self._execute(context)
@@ -124,7 +125,7 @@ class UpdateDataFromSverchok(bpy.types.Operator, tool.Ifc.Operator):
def _execute(self, context):
obj = context.active_object
element = tool.Ifc.get_entity(obj)
- props = obj.BIMSverchokProperties
+ props = tool.Model.get_sverchok_props(obj)
node_group = props.node_group
if node_group:
@@ -192,11 +193,11 @@ class ImportSverchokGraph(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
filename_ext = ".json"
def _execute(self, context):
- import sverchok
+ import sverchok.utils.sv_json_import
importer = sverchok.utils.sv_json_import.JSONImporter.init_from_path(self.filepath)
obj = context.active_object
- props = obj.BIMSverchokProperties
+ props = tool.Model.get_sverchok_props(obj)
node_group = context.scene.io_panel_properties.import_tree
if not node_group:
@@ -231,10 +232,10 @@ class ExportSverchokGraph(bpy.types.Operator, tool.Ifc.Operator, ExportHelper):
compress: bpy.props.BoolProperty()
def _execute(self, context):
- import sverchok
+ import sverchok.utils.sv_json_export
obj = context.active_object
- props = obj.BIMSverchokProperties
+ props = tool.Model.get_sverchok_props(obj)
ng = props.node_group
destination_path = self.filepath
if not destination_path.lower().endswith(".json"):
@@ -273,7 +274,8 @@ class ExportSverchokGraph(bpy.types.Operator, tool.Ifc.Operator, ExportHelper):
return {"FINISHED"}
def draw(self, context):
- graph_name = context.active_object.BIMSverchokProperties.node_group.name
+ props = tool.Model.get_sverchok_props(context.active_object)
+ graph_name = props.node_group.name
self.layout.label(text=f'Save node tree "{graph_name}" into json:')
col = self.layout.column(heading="Options") # new syntax in >= 2.90
diff --git a/src/bonsai/bonsai/bim/module/model/task.py b/src/bonsai/bonsai/bim/module/model/task.py
index a6fe2607a2..72d2ee4556 100644
--- a/src/bonsai/bonsai/bim/module/model/task.py
+++ b/src/bonsai/bonsai/bim/module/model/task.py
@@ -31,11 +31,11 @@ def calculate_quantities(usecase_path, ifc_file: ifcopenshell.file, settings):
return
task = next(e for e in ifc_file.get_inverse(element) if e.is_a("IfcTask"))
qto = ifcopenshell.api.pset.add_qto(
- ifc_file, should_run_listeners=False, product=task, name="Qto_TaskBaseQuantities"
+ ifc_file, should_run_listeners=False, product=task, name="Qto_TaskBaseQuantities" # ty:ignore[unknown-argument]
)
ifcopenshell.api.pset.edit_qto(
ifc_file,
- should_run_listeners=False,
+ should_run_listeners=False, # ty:ignore[unknown-argument]
qto=qto,
properties={
"StandardWork": ifcopenshell.util.date.ifc2datetime(element.ScheduleDuration).days,
diff --git a/src/bonsai/bonsai/bim/module/model/ui.py b/src/bonsai/bonsai/bim/module/model/ui.py
index 512eaf3fdf..eef71ed433 100644
--- a/src/bonsai/bonsai/bim/module/model/ui.py
+++ b/src/bonsai/bonsai/bim/module/model/ui.py
@@ -360,7 +360,7 @@ class BIM_PT_sverchok(bpy.types.Panel):
self.layout.label(text="Requires Sverchok Add-on", icon="ERROR")
return
- props = context.active_object.BIMSverchokProperties
+ props = tool.Model.get_sverchok_props(context.active_object)
self.layout.prop_search(props, "node_group", bpy.data, "node_groups")
self.layout.operator("bim.create_new_sverchok_graph", icon="ADD")
diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py
index 441566e3e6..4d0c9b3de7 100644
--- a/src/bonsai/bonsai/bim/module/model/wall.py
+++ b/src/bonsai/bonsai/bim/module/model/wall.py
@@ -1268,27 +1268,6 @@ class DumbWallJoiner:
bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=wall2)
return wall2
- def join_Z(self, wall1, slab2):
- element1 = tool.Ifc.get_entity(wall1)
- element2 = tool.Ifc.get_entity(slab2)
-
- for rel in element1.ConnectedFrom:
- if rel.is_a() == "IfcRelConnectsElements" and rel.Description == "TOP":
- ifcopenshell.api.geometry.disconnect_element(
- tool.Ifc.get(),
- relating_element=rel.RelatingElement,
- related_element=element1,
- )
-
- ifcopenshell.api.geometry.connect_element(
- tool.Ifc.get(),
- relating_element=element2,
- related_element=element1,
- description="TOP",
- )
-
- tool.Model.recreate_wall(element1, wall1)
-
def set_axis(self, wall, p1, p2):
axis = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Plan", "Axis", "GRAPH_VIEW")
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
@@ -1333,29 +1312,6 @@ class DumbWallJoiner:
self.set_axis(element1, p1, p2)
tool.Model.recreate_wall(element1, wall1)
- def join_T(self, wall1: bpy.types.Object, wall2: bpy.types.Object) -> None:
- element1 = tool.Ifc.get_entity(wall1)
- element2 = tool.Ifc.get_entity(wall2)
- axis1 = tool.Model.get_wall_axis(wall1)
- axis2 = tool.Model.get_wall_axis(wall2)
- intersect = tool.Cad.intersect_edges(axis1["reference"], axis2["reference"])
- if intersect:
- intersect, _ = intersect
- else:
- return
- connection = "ATEND" if tool.Cad.edge_percent(intersect, axis1["reference"]) > 0.5 else "ATSTART"
-
- ifcopenshell.api.geometry.connect_path(
- tool.Ifc.get(),
- related_element=element1,
- relating_element=element2,
- relating_connection="ATPATH",
- related_connection=connection,
- description="BUTT",
- )
-
- tool.Model.recreate_wall(element1, wall1, axis1["reference"], axis1["reference"])
-
def connect(self, obj1: bpy.types.Object, obj2: bpy.types.Object) -> None:
wall1 = tool.Ifc.get_entity(obj1)
wall2 = tool.Ifc.get_entity(obj2)
diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py
index 5f7cf7699d..828fc2c21f 100644
--- a/src/bonsai/bonsai/bim/module/model/workspace.py
+++ b/src/bonsai/bonsai/bim/module/model/workspace.py
@@ -943,7 +943,7 @@ class EditObjectUI:
if "LAYER2" in AuthoringData.data["selected_material_usages"]:
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
add_layout_hotkey_operator(
- cls.layout, "Extend To Underside", "S_E", bpy.ops.bim.extend_to_underside.__doc__, ui_context
+ cls.layout, "Extend To Underside", "S_E", bpy.ops.bim.extend_walls_to_underside.__doc__, ui_context
)
if AuthoringData.data["is_flippable_element"]:
diff --git a/src/bonsai/bonsai/bim/module/nest/decorator.py b/src/bonsai/bonsai/bim/module/nest/decorator.py
index 66608b0171..4a3637caa6 100644
--- a/src/bonsai/bonsai/bim/module/nest/decorator.py
+++ b/src/bonsai/bonsai/bim/module/nest/decorator.py
@@ -101,7 +101,7 @@ class NestDecorator:
cls.is_installed = False
def dotted_line_shader(self):
- vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
+ vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments]
vert_out.smooth("FLOAT", "v_ArcLength")
shader_info = gpu.types.GPUShaderCreateInfo()
@@ -215,8 +215,6 @@ class NestDecorator:
self.draw_batch("LINES", line_z, color, [(0, 1)])
else:
self.draw_batch("POINTS", [location], color)
- # if context.scene.BIMNestProperties.in_aggregate_mode:
- # return
components = ifcopenshell.util.element.get_components(tool.Ifc.get_entity(nest))
components_objs = [tool.Ifc.get_object(p) for p in components]
components_objs.append(nest)
diff --git a/src/bonsai/bonsai/bim/module/project/__init__.py b/src/bonsai/bonsai/bim/module/project/__init__.py
index db642c18c2..e83da49c5b 100644
--- a/src/bonsai/bonsai/bim/module/project/__init__.py
+++ b/src/bonsai/bonsai/bim/module/project/__init__.py
@@ -76,6 +76,7 @@ classes = (
operator.UnlinkIfc,
operator.UnloadLink,
workspace.ExploreHotkey,
+ operator.GenerateUVMap,
prop.LibraryBreadcrumb,
prop.LibraryElement,
prop.FilterCategory,
diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py
index a0768e0cf5..e24f6f41d8 100644
--- a/src/bonsai/bonsai/bim/module/project/operator.py
+++ b/src/bonsai/bonsai/bim/module/project/operator.py
@@ -178,9 +178,18 @@ class SelectLibraryFile(bpy.types.Operator, IFCFileSelector, ImportHelper):
bl_description = (
"Select an IFC file that can be used as a library.\n\nALT+click to reload the current loaded library file."
)
- filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"})
- append_all: bpy.props.BoolProperty(default=False)
- use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
+ filter_glob: bpy.props.StringProperty(
+ default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"}
+ ) # pyright: ignore[reportRedeclaration]
+ append_all: bpy.props.BoolProperty(default=False) # pyright: ignore[reportRedeclaration]
+ use_relative_path: bpy.props.BoolProperty(
+ name="Use Relative Path", default=False
+ ) # pyright: ignore[reportRedeclaration]
+
+ if TYPE_CHECKING:
+ filter_glob: str
+ append_all: bool
+ use_relative_path: bool
reload_previous_file = False
@@ -558,7 +567,11 @@ class AppendEntireLibrary(bpy.types.Operator, tool.Ifc.Operator):
class AppendLibraryElementByQuery(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.append_library_element_by_query"
bl_label = "Append Library Element By Query"
- query: bpy.props.StringProperty(name="Query")
+
+ query: bpy.props.StringProperty(name="Query") # pyright: ignore[reportRedeclaration]
+
+ if TYPE_CHECKING:
+ query: str
@classmethod
def poll(cls, context):
@@ -587,9 +600,16 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
"Append element to the current project.\n\n"
"ALT+CLICK to skip reusing materials, profiles, styles based on their name (may result in duplicates)"
)
- definition: bpy.props.IntProperty()
- prop_index: bpy.props.IntProperty()
- assume_unique_by_name: bpy.props.BoolProperty(name="Assume Unique By Name", default=True, options={"SKIP_SAVE"})
+ definition: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
+ prop_index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
+ assume_unique_by_name: bpy.props.BoolProperty(
+ name="Assume Unique By Name", default=True, options={"SKIP_SAVE"}
+ ) # pyright: ignore[reportRedeclaration]
+
+ if TYPE_CHECKING:
+ definition: int
+ prop_index: int
+ assume_unique_by_name: bool
file: ifcopenshell.file
@@ -618,8 +638,6 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
if not element:
return {"FINISHED"}
if element.is_a("IfcTypeProduct"):
- # Store opening template from library if it exists
- self.store_opening_template_from_library(element, library_file)
self.import_type_from_ifc(element, context)
elif element.is_a("IfcProduct"):
# NOTE: Non-types are not exposed in UI directly
@@ -720,53 +738,6 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
if element.is_a("IfcSurfaceStyle") and not tool.Ifc.get_object_by_identifier(element.id()):
ifc_importer.create_style(element)
- def store_opening_template_from_library(
- self, element: ifcopenshell.entity_instance, library_file: ifcopenshell.file
- ) -> None:
- """
- Find an opening representation in the library and copy it to the current file
- as a template. Store the template ID on the type for later retrieval.
- """
- try:
- library_element = library_file.by_guid(element.GlobalId)
- except:
- return
-
- # Find occurrences with openings in the library
- library_occurrences = ifcopenshell.util.element.get_types(library_element)
-
- for occurrence in library_occurrences:
- if not getattr(occurrence, "FillsVoids", None):
- continue
-
- library_opening = occurrence.FillsVoids[0].RelatingOpeningElement
- library_opening_rep = ifcopenshell.util.representation.get_representation(
- library_opening, "Model", "Body", "MODEL_VIEW"
- )
-
- if not library_opening_rep:
- continue
-
- # Check if mapped representation
- if (
- library_opening_rep.RepresentationType == "MappedRepresentation"
- and len(library_opening_rep.Items) == 1
- and library_opening_rep.Items[0].is_a("IfcMappedItem")
- ):
-
- mapped_rep = library_opening_rep.Items[0].MappingSource.MappedRepresentation
-
- # Store ALL representation types (Tessellation, SweptSolid, etc.)
- template_rep = ifcopenshell.util.element.copy_deep(
- self.file, mapped_rep, exclude=["IfcGeometricRepresentationContext"]
- )
-
- # Store reference in type's Description
- current_desc = element.Description or ""
- element.Description = f"{current_desc}||BonsaiOpeningTemplate:{template_rep.id()}"
- return
- break
-
class EditProjectLibrary(bpy.types.Operator):
bl_idname = "bim.edit_project_library"
@@ -988,24 +959,28 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
bl_label = "Load Project"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Load an existing IFC project"
- filepath: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE"})
- filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml;*.ifcsqlite", options={"HIDDEN"})
- is_advanced: bpy.props.BoolProperty(
+ filepath: bpy.props.StringProperty(
+ subtype="FILE_PATH", options={"SKIP_SAVE"}
+ ) # pyright: ignore[reportRedeclaration]
+ filter_glob: bpy.props.StringProperty(
+ default="*.ifc;*.ifczip;*.ifcxml;*.ifcsqlite", options={"HIDDEN"}
+ ) # pyright: ignore[reportRedeclaration]
+ is_advanced: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
name="Enable Advanced Mode",
description="Load IFC file with advanced settings. Checking this option will skip loading IFC file and will open advanced load settings",
default=False,
)
- use_relative_path: bpy.props.BoolProperty(
+ use_relative_path: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
name="Use Relative Path",
description="Store the IFC project path relative to the .blend file. Requires .blend file to be saved",
default=False,
)
- should_start_fresh_session: bpy.props.BoolProperty(
+ should_start_fresh_session: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
name="Should Start Fresh Session",
description="Clear current Blender session before loading IFC. Not supported with 'Use Relative Path' option",
default=True,
)
- import_without_ifc_data: bpy.props.BoolProperty(
+ import_without_ifc_data: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
name="Import Without IFC Data",
description=(
"Import IFC objects as Blender objects without any IFC metadata and authoring capabilities."
@@ -1013,9 +988,20 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
),
default=False,
)
- use_detailed_tooltip: bpy.props.BoolProperty(default=False, options={"HIDDEN"})
+ use_detailed_tooltip: bpy.props.BoolProperty(
+ default=False, options={"HIDDEN"}
+ ) # pyright: ignore[reportRedeclaration]
filename_ext = ".ifc"
+ if TYPE_CHECKING:
+ filepath: str
+ filter_glob: str
+ is_advanced: bool
+ use_relative_path: bool
+ should_start_fresh_session: bool
+ import_without_ifc_data: bool
+ use_detailed_tooltip: bool
+
@classmethod
def description(cls, context, properties):
tooltip = cls.bl_description
@@ -1116,7 +1102,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
else:
return self.finish_loading_project(context)
- def finish_loading_project(self, context):
+ def finish_loading_project(self, context: bpy.types.Context) -> set["rna_enums.OperatorReturnItems"]:
try:
filepath = self.get_filepath()
if not self.is_existing_ifc_file():
@@ -1314,7 +1300,10 @@ class ToggleFilterCategories(bpy.types.Operator):
bl_idname = "bim.toggle_filter_categories"
bl_label = "Toggle Filter Categories"
bl_options = {"REGISTER", "UNDO"}
- should_select: bpy.props.BoolProperty(name="Should Select", default=True)
+ should_select: bpy.props.BoolProperty(name="Should Select", default=True) # pyright: ignore[reportRedeclaration]
+
+ if TYPE_CHECKING:
+ should_select: bool
def execute(self, context):
props = tool.Project.get_project_props()
@@ -1338,6 +1327,14 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
default=False,
)
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True)
+ query: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration]
+ name="Query",
+ description=(
+ "Custom selector query to use to load element from a linked model. E.g. 'IfcElement'.\n\n"
+ "Default query - IfcElement, but excluding IfcProxy, IfcSpatialStructureElement, IfcSpatialElement, IfcFeatureElement."
+ ),
+ )
+
filename_ext = ".ifc"
if TYPE_CHECKING:
@@ -1347,20 +1344,25 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
filter_glob: str
use_relative_path: bool
use_cache: bool
+ query: str
def draw(self, context):
+ assert self.layout
pprops = tool.Project.get_project_props()
row = self.layout.row()
row.prop(self, "use_relative_path")
row = self.layout.row()
row.prop(self, "use_cache")
row = self.layout.row()
- row.prop(pprops, "false_origin_mode")
+ row.label(text="False Origin Mode:")
+ row = self.layout.row()
+ row.prop(pprops, "false_origin_mode", text="")
if pprops.false_origin_mode == "MANUAL":
row = self.layout.row()
row.prop(pprops, "false_origin")
row = self.layout.row()
row.prop(pprops, "project_north")
+ self.layout.prop(self, "query", placeholder="IfcElement")
def _execute(self, context):
start = time.time()
@@ -1393,7 +1395,7 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
new.ifc_definition_id = reference.id()
new.name = filepath
new.filepath = filepath
- bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache)
+ bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache, query=self.query)
class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator):
@@ -1401,7 +1403,11 @@ class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Unlink IFC"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Remove the selected file from the link list"
- link_index: bpy.props.IntProperty(name="Link Index")
+
+ link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
+
+ if TYPE_CHECKING:
+ link_index: int
def _execute(self, context):
props = tool.Project.get_project_props()
@@ -1421,7 +1427,11 @@ class UnloadLink(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Unload Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Unload the selected linked file"
- link_index: bpy.props.IntProperty(name="Link Index")
+
+ link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
+
+ if TYPE_CHECKING:
+ link_index: int
def _execute(self, context):
link = tool.Project.get_project_props().links[self.link_index]
@@ -1446,10 +1456,12 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True) # pyright: ignore[reportRedeclaration]
+ query: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
link_index: int
use_cache: bool
+ query: str
def _execute(self, context):
self.link = tool.Project.get_project_props().links[self.link_index]
@@ -1491,8 +1503,20 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
def link_ifc(self) -> Union[set[str], None]:
blend_filepath = self.filepath_.with_suffix(".ifc.cache.blend")
h5_filepath = self.filepath_.with_suffix(".ifc.cache.h5")
+ json_filepath = self.filepath_.with_suffix(".ifc.cache.json")
- if not self.use_cache and blend_filepath.exists():
+ def should_clear_cache() -> bool:
+ 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.
+ query = data.get("query", "")
+ return query != self.query
+
+ if should_clear_cache():
os.remove(blend_filepath)
if not blend_filepath.exists():
@@ -1520,7 +1544,7 @@ def run():
pprops.project_north = "{pprops.project_north}"
# Use absolute path to be safe from cwd changes.
try:
- bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}")
+ bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}", query={repr(self.query)})
except RuntimeError as e:
# Operator failed (returned CANCELLED with error report)
print(f"Failed to load linked project: {{e}}")
@@ -1606,7 +1630,11 @@ class ReloadLink(bpy.types.Operator):
bl_label = "Reload Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Reload the selected file"
- link_index: bpy.props.IntProperty(name="Link Index")
+
+ link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
+
+ if TYPE_CHECKING:
+ link_index: int
def execute(self, context):
bpy.ops.bim.unload_link(link_index=self.link_index)
@@ -1618,7 +1646,11 @@ class ToggleLinkSelectability(bpy.types.Operator):
bl_label = "Toggle Link Selectability"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Toggle selectability"
- link_index: bpy.props.IntProperty(name="Link Index")
+
+ link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
+
+ if TYPE_CHECKING:
+ link_index: int
def execute(self, context):
props = tool.Project.get_project_props()
@@ -1788,7 +1820,11 @@ class SelectLinkHandle(bpy.types.Operator):
bl_label = "Select Link Handle"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Select link empty object handle"
- link_index: bpy.props.IntProperty(name="Link Index")
+
+ link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
+
+ if TYPE_CHECKING:
+ link_index: int
def execute(self, context):
props = tool.Project.get_project_props()
@@ -1846,11 +1882,28 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
bl_options = {"REGISTER", "UNDO"}
filename_ext = ".ifc"
supported_filexts = (".ifc", ".ifczip", ".ifcjson")
- filter_glob: bpy.props.StringProperty(default=";".join(f"*{ext}" for ext in supported_filexts), options={"HIDDEN"})
- json_version: bpy.props.EnumProperty(items=[("4", "4", ""), ("5a", "5a", "")], name="IFC JSON Version")
- json_compact: bpy.props.BoolProperty(name="Export Compact IFCJSON", default=False)
- should_save_as: bpy.props.BoolProperty(name="Should Save As", default=False, options={"HIDDEN"})
- use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
+ filter_glob: bpy.props.StringProperty(
+ default=";".join(f"*{ext}" for ext in supported_filexts), options={"HIDDEN"}
+ ) # pyright: ignore[reportRedeclaration]
+ json_version: bpy.props.EnumProperty(
+ items=[("4", "4", ""), ("5a", "5a", "")], name="IFC JSON Version"
+ ) # pyright: ignore[reportRedeclaration]
+ json_compact: bpy.props.BoolProperty(
+ name="Export Compact IFCJSON", default=False
+ ) # pyright: ignore[reportRedeclaration]
+ should_save_as: bpy.props.BoolProperty(
+ name="Should Save As", default=False, options={"HIDDEN"}
+ ) # pyright: ignore[reportRedeclaration]
+ use_relative_path: bpy.props.BoolProperty(
+ name="Use Relative Path", default=False
+ ) # pyright: ignore[reportRedeclaration]
+
+ if TYPE_CHECKING:
+ filter_glob: str
+ json_version: str
+ json_compact: bool
+ should_save_as: bool
+ use_relative_path: bool
@classmethod
def poll(cls, context):
@@ -2000,6 +2053,12 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
bl_description = "Operator is used to load a project .cache.blend to then link it to the IFC file."
bl_options = {"REGISTER", "UNDO"}
+ query: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
+ """See ``bim.link_ifc``."""
+
+ if TYPE_CHECKING:
+ query: str
+
file: ifcopenshell.file
meshes: dict[str, bpy.types.Mesh]
# Material names is derived from diffuse as in 'r-g-b-a'.
@@ -2049,14 +2108,17 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
tool.Loader.settings.context_settings = tool.Loader.create_settings()
tool.Loader.settings.gross_context_settings = tool.Loader.create_settings(is_gross=True)
- self.elements = set(self.file.by_type("IfcElement"))
- if self.file.schema in ("IFC2X3", "IFC4"):
- self.elements |= set(self.file.by_type("IfcProxy"))
- if self.file.schema == "IFC2X3":
- self.elements |= set(self.file.by_type("IfcSpatialStructureElement"))
+ if self.query:
+ self.elements = ifcopenshell.util.selector.filter_elements(self.file, self.query)
else:
- self.elements |= set(self.file.by_type("IfcSpatialElement"))
- self.elements -= set(self.file.by_type("IfcFeatureElement"))
+ self.elements = set(self.file.by_type("IfcElement"))
+ if self.file.schema in ("IFC2X3", "IFC4"):
+ self.elements |= set(self.file.by_type("IfcProxy"))
+ if self.file.schema == "IFC2X3":
+ self.elements |= set(self.file.by_type("IfcSpatialStructureElement"))
+ else:
+ self.elements |= set(self.file.by_type("IfcSpatialElement"))
+ self.elements -= set(self.file.by_type("IfcFeatureElement"))
if tool.Loader.settings.false_origin_mode == "MANUAL" and tool.Loader.settings.false_origin:
tool.Loader.set_manual_blender_offset(self.file)
@@ -2081,6 +2143,7 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
"false_origin_mode": pprops.false_origin_mode,
"false_origin": pprops.false_origin,
"project_north": pprops.project_north,
+ "query": self.query,
}
with open(self.json_filepath, "w") as f:
json.dump(data, f)
@@ -2142,6 +2205,7 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
if iterator.initialize():
while True: # Main loop.
shape = iterator.get()
+ assert isinstance(shape, W.TriangulationElement)
results.add(self.file.by_id(shape.id))
geometry = shape.geometry
@@ -2372,18 +2436,23 @@ class HideQueriedLinkedElement(bpy.types.Operator):
bl_label = "Hide Queried Linked Element"
bl_description = (
"Hide geometry for currently queried linked element.\n\n"
- "ALT+Click (or ALT+H in Explore Tool) to unhide all geometry for currently selected linked model.\n"
- "(Not Yet Implemented) SHIFT+Click to hide everything but currently queried element."
+ "SHIFT+Click (or SHIFT+H in Explore Tool) to hide everything "
+ "in the currently selected model, but the queried element.\n"
+ "ALT+Click (or ALT+H in Explore Tool) to unhide all geometry for currently selected linked model.\n\n"
+ "Known limitation: doesn't work with UNDO."
)
bl_options = {"REGISTER", "UNDO"}
unhide_all: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
+ hide_all_except: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
unhide_all: bool
+ hide_all_except: bool
def invoke(self, context, event):
self.unhide_all = event.alt
+ self.hide_all_except = event.shift
return self.execute(context)
def execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
@@ -2392,6 +2461,9 @@ class HideQueriedLinkedElement(bpy.types.Operator):
if self.unhide_all:
return self.run_unhide_all()
+ if self.hide_all_except:
+ return self.run_hide_all_except()
+
obj = props.queried_obj
if not obj:
self.report({"INFO"}, "No object is queried to hide.")
@@ -2413,6 +2485,21 @@ class HideQueriedLinkedElement(bpy.types.Operator):
self.report({"INFO"}, "All linked model geometry is unhidden.")
return {"FINISHED"}
+ def run_hide_all_except(self) -> set["rna_enums.OperatorReturnItems"]:
+ props = tool.Project.get_project_props()
+ obj = props.queried_obj
+ if not obj:
+ self.report({"INFO"}, "No object is queried.")
+ return {"FINISHED"}
+ link = props.active_link
+ if not link:
+ self.report({"INFO"}, "No linked model is currently selected.")
+ return {"FINISHED"}
+ guid = props.queried_guid
+ tool.Project.Link.hide_all_elements_except(link, obj, guid)
+ self.report({"INFO"}, "All other linked model geometry is now hidden.")
+ return {"FINISHED"}
+
class AppendInspectedLinkedElement(AppendLibraryElement):
bl_idname = "bim.append_inspected_linked_element"
@@ -2471,7 +2558,7 @@ class EnableCulling(bpy.types.Operator):
self.total_mousemoves = 0
self.cullable_objects = []
- def modal(self, context, event):
+ def modal(self, context, event) -> set["rna_enums.OperatorReturnItems"]:
if not LinksData.enable_culling:
for obj in bpy.context.visible_objects:
if obj.type == "MESH" and obj.name.startswith("Ifc"):
@@ -2502,7 +2589,7 @@ class EnableCulling(bpy.types.Operator):
return {"PASS_THROUGH"}
- def is_view_changed(self, context):
+ def is_view_changed(self, context: bpy.types.Context) -> bool:
view_matrix = context.region_data.view_matrix
projection_matrix = context.region_data.window_matrix
vp_matrix = projection_matrix @ view_matrix
@@ -2517,7 +2604,7 @@ class EnableCulling(bpy.types.Operator):
return True
return False
- def is_object_in_view(self, obj, context, camera_position):
+ def is_object_in_view(self, obj: bpy.types.Object, context: bpy.types.Context, camera_position: Vector) -> bool:
# Get the view matrix and the projection matrix from the active viewport
view_matrix = context.region_data.view_matrix
projection_matrix = context.region_data.window_matrix
@@ -2544,7 +2631,7 @@ class EnableCulling(bpy.types.Operator):
return False
return True
- def invoke(self, context, event):
+ def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set["rna_enums.OperatorReturnItems"]:
LinksData.enable_culling = True
self.cullable_objects = []
for obj in bpy.context.visible_objects:
@@ -2698,10 +2785,7 @@ class CreateClippingPlane(bpy.types.Operator):
self.report({"INFO"}, "Maximum of six clipping planes allowed.")
return {"FINISHED"}
- assert context.screen
- for area in context.screen.areas:
- if area.type == "VIEW_3D":
- area.tag_redraw()
+ tool.Blender.update_all_viewports(context)
assert context.region and context.region_data
region = context.region
@@ -2834,8 +2918,16 @@ class IFCFileHandlerOperator(bpy.types.Operator):
bl_label = "Import .ifc file"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
- directory: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE", "HIDDEN"})
- files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement, options={"SKIP_SAVE", "HIDDEN"})
+ directory: bpy.props.StringProperty(
+ subtype="FILE_PATH", options={"SKIP_SAVE", "HIDDEN"}
+ ) # pyright: ignore[reportRedeclaration]
+ files: bpy.props.CollectionProperty(
+ type=bpy.types.OperatorFileListElement, options={"SKIP_SAVE", "HIDDEN"}
+ ) # pyright: ignore[reportRedeclaration]
+
+ if TYPE_CHECKING:
+ directory: str
+ files: list[bpy.types.OperatorFileListElement]
def invoke(self, context, event):
# Keeping code in .invoke() as we'll probably add some
@@ -2886,7 +2978,10 @@ class MeasureTool(bpy.types.Operator, PolylineOperator):
bl_label = "Measure Tool"
bl_options = {"REGISTER", "UNDO"}
- measure_type: bpy.props.StringProperty()
+ measure_type: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
+
+ if TYPE_CHECKING:
+ measure_type: str
@classmethod
def poll(cls, context):
@@ -2982,7 +3077,10 @@ class MeasureFaceAreaTool(bpy.types.Operator, PolylineOperator):
bl_label = "Measure Face Area Tool"
bl_options = {"REGISTER", "UNDO"}
- measure_type: bpy.props.StringProperty()
+ measure_type: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
+
+ if TYPE_CHECKING:
+ measure_type: str
@classmethod
def poll(cls, context):
@@ -3086,7 +3184,10 @@ class ClearMeasurement(bpy.types.Operator):
@classmethod
def poll(cls, context):
polyline_props = tool.Model.get_polyline_props()
- return len(polyline_props.measurement_polyline) > 0
+ if len(polyline_props.measurement_polyline) > 0:
+ return True
+ cls.poll_message_set("No measurement to clear.")
+ return False
def execute(self, context):
polyline_props = tool.Model.get_polyline_props()
@@ -3190,7 +3291,7 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator):
super().invoke(context, event)
return {"RUNNING_MODAL"}
- def cancel_tool(self, context):
+ def cancel_tool(self, context: bpy.types.Context) -> set["rna_enums.OperatorReturnItems"]:
context.workspace.status_text_set(text=None)
if hasattr(self, "tool_state"):
self.tool_state.plane_method = None
@@ -3198,7 +3299,7 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator):
tool.Blender.update_viewport()
return {"CANCELLED"}
- def handle_custom_instructions(self, context):
+ def handle_custom_instructions(self, context: bpy.types.Context) -> None:
if len(self.selected_points) == 0:
instruction_text = "Click First Point on Image"
elif len(self.selected_points) == 1:
@@ -3213,14 +3314,14 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator):
context.workspace.status_text_set(text=instruction_text)
- def calculate_distance(self):
+ def calculate_distance(self) -> None:
if len(self.selected_points) == 2:
point1 = self.selected_points[0]
point2 = self.selected_points[1]
distance_3d = (point2 - point1).length
self.calculated_distance = distance_3d / self.unit_scale
- def apply_scaling(self, context):
+ def apply_scaling(self, context: bpy.types.Context) -> set["rna_enums.OperatorReturnItems"]:
if len(self.selected_points) != 2:
self.report({"ERROR"}, "Two points must be selected")
return {"CANCELLED"}
@@ -3278,7 +3379,10 @@ class LoadBlendMetadataAndIFC(bpy.types.Operator):
bl_idname = "bim.load_blend_metadata_and_ifc"
bl_label = "Load Blend Metadata and IFC"
bl_options = {"REGISTER", "UNDO"}
- filepath: bpy.props.StringProperty(name="IFC File Path", default="")
+ filepath: bpy.props.StringProperty(name="IFC File Path", default="") # pyright: ignore[reportRedeclaration]
+
+ if TYPE_CHECKING:
+ filepath: str
def execute(self, context):
ifc_file = self.filepath
@@ -3312,3 +3416,19 @@ class LoadBlendMetadataAndIFC(bpy.types.Operator):
bpy.app.handlers.load_post.append(load_handler)
bpy.ops.wm.open_mainfile(filepath=metadata_path)
return {"FINISHED"}
+
+
+class GenerateUVMap(bpy.types.Operator):
+ bl_idname = "bim.generate_uv_map"
+ bl_label = "Generate UV Map"
+ bl_description = "Generate UV map for selected mesh."
+ bl_options = {"REGISTER", "UNDO", "INTERNAL"}
+
+ def execute(self, context):
+ obj = context.active_object
+ if not obj or not isinstance(obj.data, bpy.types.Mesh):
+ self.report({"ERROR"}, "No valid mesh selected.")
+ return {"CANCELLED"}
+ tool.Loader.load_generated_uv_map(obj.data)
+ self.report({"INFO"}, "Generated UV map for selected mesh.")
+ return {"FINISHED"}
diff --git a/src/bonsai/bonsai/bim/module/project/prop.py b/src/bonsai/bonsai/bim/module/project/prop.py
index 5524f7efa7..5ba0cf6f28 100644
--- a/src/bonsai/bonsai/bim/module/project/prop.py
+++ b/src/bonsai/bonsai/bim/module/project/prop.py
@@ -230,11 +230,11 @@ class Link(PropertyGroup):
)
georeferenced: EnumProperty(
name="Georeferenced",
- description="Georeferencing status: compatibility between host and linked model",
+ description="Georeferencing status, compatibility between host and linked model",
items=[
- ("NONE", "No Georef", "Linked model has no georeferencing"),
- ("NOT_COMPATIBLE", "Not Compatible", "Has geo data but CRS differ from host"),
- ("FULL_COMPATIBLE", "Full Compatible", "Both CRS name and vertical datum match host"),
+ ("NONE", "No Georef", "Linked model has no georeferencing", "QUESTION", 0),
+ ("NOT_COMPATIBLE", "Not Compatible", "Has geo data but CRS differ from host", "ERROR", 1),
+ ("FULL_COMPATIBLE", "Full Compatible", "Both CRS name and vertical datum match host", "WORLD", 2),
],
default="NONE",
)
diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py
index d86dd3cf5b..dfe8e44987 100644
--- a/src/bonsai/bonsai/bim/module/project/ui.py
+++ b/src/bonsai/bonsai/bim/module/project/ui.py
@@ -255,7 +255,7 @@ class BIM_PT_project(Panel):
row = self.layout.row(align=True)
row.operator("bim.load_project_elements")
- def draw_editing_buttons(self, context, row):
+ def draw_editing_buttons(self, context: object, row: bpy.types.UILayout) -> None:
pprops = self.props
if tool.Ifc.get():
if pprops.is_editing:
@@ -496,7 +496,7 @@ class BIM_PT_links(Panel):
row.operator("bim.reload_link", text="", icon="FILE_REFRESH").link_index = index
else:
row.operator("bim.load_link", text="", icon="LINKED").link_index = index
- row.operator("bim.unlink_ifc", text="", icon="X").link_index = index
+ row.operator("bim.unlink_ifc", text="", icon="X").link_index = index
self.layout.template_list("BIM_UL_links", "", self.props, "links", self.props, "active_link_index")
if LinksData.enable_culling:
@@ -619,12 +619,14 @@ class BIM_UL_links(UIList):
):
row = layout.row(align=True)
if item.is_loaded:
- if item.georeferenced == "NONE":
- row.label(text="", icon="QUESTION")
- elif item.georeferenced == "NOT_COMPATIBLE":
- row.label(text="", icon="ERROR")
- elif item.georeferenced == "FULL_COMPATIBLE":
- row.label(text="", icon="WORLD")
+ from bonsai.bim.module.project.prop import Link
+
+ s = Link.bl_rna
+ geo_prop = s.properties["georeferenced"]
+ assert isinstance(geo_prop, bpy.types.EnumProperty)
+ enum_item = geo_prop.enum_items[item.georeferenced]
+ op = row.operator("bim.show_description", text="", icon=enum_item.icon, emboss=False)
+ op.description = f"{geo_prop.description}\n{enum_item.name}: {enum_item.description}"
if item.has_transformation:
row.label(text="", icon="OBJECT_ORIGIN")
diff --git a/src/bonsai/bonsai/bim/module/project/workspace.py b/src/bonsai/bonsai/bim/module/project/workspace.py
index 5437d1270b..bd60f4975a 100644
--- a/src/bonsai/bonsai/bim/module/project/workspace.py
+++ b/src/bonsai/bonsai/bim/module/project/workspace.py
@@ -41,6 +41,7 @@ class ExploreTool(bpy.types.WorkSpaceTool):
("bim.explore_hotkey", {"type": "M", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_M")]}),
("bim.explore_hotkey", {"type": "S", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_S")]}),
("bim.explore_hotkey", {"type": "H", "value": "PRESS"}, {"properties": [("hotkey", "H")]}),
+ ("bim.explore_hotkey", {"type": "H", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_H")]}),
("bim.explore_hotkey", {"type": "H", "value": "PRESS", "alt": True}, {"properties": [("hotkey", "A_H")]}),
)
@@ -70,21 +71,26 @@ class ExploreTool(bpy.types.WorkSpaceTool):
row = layout.row(align=True)
row.label(text="", icon="EVENT_SHIFT")
row.label(text="", icon="EVENT_M")
- row = layout.row(align=True)
op = row.operator("bim.explore_hotkey", text="Measure Tool", icon="CON_DISTLIMIT")
op.hotkey = "S_M"
row = layout.row(align=True)
row.prop(prop, "measurement_type", text="Measure Type", expand=True, icon_only=True, emboss=True)
- row = layout.row(align=True)
op = row.operator("bim.clear_measurement", text="", icon="X")
row = layout.row(align=True)
row.label(text="", icon="EVENT_SHIFT")
row.label(text="", icon="EVENT_S")
- row = layout.row(align=True)
op = row.operator("bim.explore_hotkey", text="Image Scaling Tool", icon="IMAGE_PLANE")
op.hotkey = "S_S"
- op.description = "Scale Image Annotation. Allows to scale an IfcReferenceImage. Select image, select tool. Check lower left corner instructions to select two points and provide real distance between them"
+ op.description = (
+ "Scale Image Annotation.\n\n"
+ "Allows to scale an IfcReferenceImage.\n\n"
+ "Select image, select tool. "
+ "Check lower left corner instructions to select two points and provide real distance between them"
+ )
+
+ row = layout.row(align=True)
+ row.operator("bim.generate_uv_map", icon="UV")
class ExploreHotkey(bpy.types.Operator):
@@ -147,5 +153,8 @@ class ExploreHotkey(bpy.types.Operator):
def hotkey_H(self) -> None:
bpy.ops.bim.hide_queried_linked_element()
+ def hotkey_S_H(self) -> None:
+ bpy.ops.bim.hide_queried_linked_element(hide_all_except=True)
+
def hotkey_A_H(self) -> None:
bpy.ops.bim.hide_queried_linked_element(unhide_all=True)
diff --git a/src/bonsai/bonsai/bim/module/pset/ui.py b/src/bonsai/bonsai/bim/module/pset/ui.py
index 03d6f8bb36..addd03a504 100644
--- a/src/bonsai/bonsai/bim/module/pset/ui.py
+++ b/src/bonsai/bonsai/bim/module/pset/ui.py
@@ -262,7 +262,7 @@ class BIM_PT_object_psets(Panel):
row = self.layout.row(align=True)
prop_with_search(row, props, "pset_name", text="")
- if props.pset_name != "BBIM_BSDD" and not props.pset_name.startswith(tool.Bsdd.identifier_url):
+ if props.pset_name != "BBIM_BSDD" and not props.pset_name.startswith(tool.Bsdd.identifier_url()):
op = row.operator("bim.add_pset", icon="ADD", text="")
op.obj = obj.name
op.obj_type = "Object"
diff --git a/src/bonsai/bonsai/bim/module/qto/calculator.py b/src/bonsai/bonsai/bim/module/qto/calculator.py
index c0e5aa474f..638d603f89 100644
--- a/src/bonsai/bonsai/bim/module/qto/calculator.py
+++ b/src/bonsai/bonsai/bim/module/qto/calculator.py
@@ -321,7 +321,7 @@ def get_gross_perimeter(o: bpy.types.Object) -> float:
return gross_perimeter
-def get_space_net_perimeter(obj: bpy.types.Object) -> float:
+def get_space_net_perimeter(obj: bpy.types.Object) -> None:
pass
diff --git a/src/bonsai/bonsai/bim/module/resource/prop.py b/src/bonsai/bonsai/bim/module/resource/prop.py
index 9f2c5ba85c..f08bacbab5 100644
--- a/src/bonsai/bonsai/bim/module/resource/prop.py
+++ b/src/bonsai/bonsai/bim/module/resource/prop.py
@@ -188,7 +188,7 @@ class BIMResourceProperties(PropertyGroup):
@property
def productivity(self) -> "BIMResourceProductivity":
assert bpy.context.scene
- productivity = bpy.context.scene.BIMResourceProductivity
+ productivity = bpy.context.scene.BIMResourceProductivity # pyright: ignore[reportAttributeAccessIssue]
assert isinstance(productivity, BIMResourceProductivity)
return productivity
diff --git a/src/bonsai/bonsai/bim/module/search/__init__.py b/src/bonsai/bonsai/bim/module/search/__init__.py
index 5033fd3eb4..b6c76733a1 100644
--- a/src/bonsai/bonsai/bim/module/search/__init__.py
+++ b/src/bonsai/bonsai/bim/module/search/__init__.py
@@ -41,6 +41,7 @@ classes = (
operator.SelectByProperty,
operator.SelectFilterElements,
operator.SelectGlobalId,
+ operator.SelectQueryElements,
operator.SelectIfcClass,
operator.SelectSimilar,
operator.ShowAllElements,
diff --git a/src/bonsai/bonsai/bim/module/search/operator.py b/src/bonsai/bonsai/bim/module/search/operator.py
index b7ba62acdb..d5a6b9b1e6 100644
--- a/src/bonsai/bonsai/bim/module/search/operator.py
+++ b/src/bonsai/bonsai/bim/module/search/operator.py
@@ -42,6 +42,8 @@ from bonsai.bim.ifc import IfcStore
from bonsai.bim.prop import StrProperty
if TYPE_CHECKING:
+ from bpy.stub_internal import rna_enums
+
from bonsai.bim.prop import BIMFacet
@@ -617,7 +619,7 @@ class SelectFilterElements(bpy.types.Operator):
return {"FINISHED"}
-class ApplyFilterFromText(Operator, tool.Ifc.Operator):
+class ApplyFilterFromText(Operator):
bl_idname = "bim.apply_filter_from_text"
bl_label = "Apply Filter Configuration"
bl_description = "Apply the JSON filter configuration from the current text block"
@@ -791,6 +793,27 @@ class Search(Operator):
return {"FINISHED"}
+class SelectQueryElements(Operator):
+ bl_idname = "bim.select_query_elements"
+ bl_label = "Select Query Elements"
+ bl_description = "Select elements matching an provided selector query"
+ bl_options = {"REGISTER", "UNDO"}
+
+ query: StringProperty(name="Query") # pyright: ignore[reportRedeclaration]
+
+ if TYPE_CHECKING:
+ query: str
+
+ def execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
+ results = ifcopenshell.util.selector.filter_elements(tool.Ifc.get(), self.query)
+ objs = [obj for e in results if isinstance(obj := tool.Ifc.get_object(e), bpy.types.Object)]
+ active_object = context.active_object or next(iter(objs), None)
+ selection = tool.Blender.validate_object_selection(context, active_object, objs)
+ tool.Blender.set_objects_selection(*selection, clear_previous_selection=False)
+ self.report({"INFO"}, f"{len(results)} Results, {len(selection.selected_objects)} Objects Selected")
+ return {"FINISHED"}
+
+
class SaveSearch(Operator, tool.Ifc.Operator):
bl_idname = "bim.save_search"
bl_label = "Save Search"
@@ -1053,8 +1076,8 @@ class ColourByProperty(Operator):
colourscheme[str(values[index])]["total"] += 1
obj.color = (*tool.Search.get_quantitative_palette(palette, value, min_value, max_value), 1)
- if areas := [a for a in context.screen.areas if a.type == "VIEW_3D"]:
- areas[0].spaces[0].shading.color_type = "OBJECT"
+ assert (space := tool.Blender.get_view3d_space())
+ space.shading.color_type = "OBJECT"
props.colourscheme.clear()
@@ -1078,16 +1101,18 @@ class ColourByProperty(Operator):
return (1, value)
def store_state(self, context):
- if areas := [a for a in context.screen.areas if a.type == "VIEW_3D"]:
- self.transaction_data = {"area": areas[0], "color_type": areas[0].spaces[0].shading.color_type}
+ if space := tool.Blender.get_view3d_space():
+ self.transaction_data = {"color_type": space.shading.color_type}
def rollback(self, data):
if data:
- data["area"].spaces[0].shading.color_type = data["color_type"]
+ assert (space := tool.Blender.get_view3d_space())
+ space.shading.color_type = data["color_type"]
def commit(self, data):
if data:
- data["area"].spaces[0].shading.color_type = "OBJECT"
+ assert (space := tool.Blender.get_view3d_space())
+ space.shading.color_type = "OBJECT"
class SelectByProperty(Operator):
@@ -1415,7 +1440,7 @@ class ShowAllElements(Operator):
return {"FINISHED"}
-class SelectSimilar(Operator, tool.Ifc.Operator):
+class SelectSimilar(Operator):
bl_idname = "bim.select_similar"
bl_label = "Select Similar"
bl_options = {"REGISTER", "UNDO"}
diff --git a/src/bonsai/bonsai/bim/module/sequence/ui.py b/src/bonsai/bonsai/bim/module/sequence/ui.py
index f758e89ef5..56847fa750 100644
--- a/src/bonsai/bonsai/bim/module/sequence/ui.py
+++ b/src/bonsai/bonsai/bim/module/sequence/ui.py
@@ -16,6 +16,8 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see .
+from __future__ import annotations
+
from typing import TYPE_CHECKING, Any, Optional
import bpy
@@ -37,8 +39,11 @@ from bonsai.bim.module.sequence.data import (
if TYPE_CHECKING:
from bonsai.bim.module.sequence.prop import (
BIMTaskTreeProperties,
+ BIMTaskTypeColor,
BIMWorkScheduleProperties,
Task,
+ TaskProduct,
+ TaskResource,
)
from bonsai.bim.prop import Attribute
@@ -799,23 +804,24 @@ class BIM_UL_task_columns(UIList):
self,
context,
layout: bpy.types.UILayout,
- data: "BIMWorkScheduleProperties",
- item: "Attribute",
+ data: BIMWorkScheduleProperties,
+ item: Attribute,
icon,
active_data,
active_propname,
):
- props = tool.Sequence.get_work_schedule_props()
if item:
row = layout.row(align=True)
row.prop(item, "name", emboss=False, text="")
- if props.sort_column == item.name:
+ if data.sort_column == item.name:
row.label(text="", icon="SORTALPHA")
row.operator("bim.remove_task_column", text="", icon="X").name = item.name
class BIM_UL_task_inputs(UIList):
- def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
+ def draw_item(
+ self, context, layout: bpy.types.UILayout, data, item: TaskProduct, icon, active_data, active_propname
+ ) -> None:
if item:
row = layout.row(align=True)
op = row.operator("bim.select_product", text="", icon="RESTRICT_SELECT_OFF")
@@ -825,7 +831,9 @@ class BIM_UL_task_inputs(UIList):
class BIM_UL_task_resources(UIList):
- def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
+ def draw_item(
+ self, context, layout: bpy.types.UILayout, data, item: TaskResource, icon, active_data, active_propname
+ ) -> None:
if item:
row = layout.row(align=True)
row.operator("bim.go_to_resource", text="", icon="STYLUS_PRESSURE").resource = item.ifc_definition_id
@@ -834,7 +842,9 @@ class BIM_UL_task_resources(UIList):
class BIM_UL_animation_colors(UIList):
- def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
+ def draw_item(
+ self, context, layout: bpy.types.UILayout, data, item: BIMTaskTypeColor, icon, active_data, active_propname
+ ) -> None:
if item:
row = layout.row()
row.prop(item, "color", text="")
@@ -842,7 +852,9 @@ class BIM_UL_animation_colors(UIList):
class BIM_UL_task_outputs(UIList):
- def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
+ def draw_item(
+ self, context, layout: bpy.types.UILayout, data, item: TaskProduct, icon, active_data, active_propname
+ ) -> None:
if item:
row = layout.row(align=True)
op = row.operator("bim.select_product", text="", icon="RESTRICT_SELECT_OFF")
@@ -851,7 +863,9 @@ class BIM_UL_task_outputs(UIList):
class BIM_UL_product_input_tasks(UIList):
- def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
+ def draw_item(
+ self, context, layout: bpy.types.UILayout, data, item: TaskProduct, icon, active_data, active_propname
+ ) -> None:
if item:
row = layout.row(align=True)
op = row.operator("bim.go_to_task", text="", icon="STYLUS_PRESSURE")
@@ -861,7 +875,9 @@ class BIM_UL_product_input_tasks(UIList):
class BIM_UL_product_output_tasks(UIList):
- def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
+ def draw_item(
+ self, context, layout: bpy.types.UILayout, data, item: TaskProduct, icon, active_data, active_propname
+ ) -> None:
if item:
row = layout.row(align=True)
op = row.operator("bim.go_to_task", text="", icon="STYLUS_PRESSURE")
@@ -886,8 +902,8 @@ class BIM_UL_tasks(UIList):
self,
context,
layout: bpy.types.UILayout,
- data: "BIMTaskTreeProperties",
- item: "Task",
+ data: BIMTaskTreeProperties,
+ item: Task,
icon,
active_data,
active_propname,
diff --git a/src/bonsai/bonsai/bim/module/spatial/operator.py b/src/bonsai/bonsai/bim/module/spatial/operator.py
index 23c0c28335..f1eb4ec4ee 100644
--- a/src/bonsai/bonsai/bim/module/spatial/operator.py
+++ b/src/bonsai/bonsai/bim/module/spatial/operator.py
@@ -512,7 +512,7 @@ class SetContainerVisibility(bpy.types.Operator):
containers -= set(tool.Ifc.get().by_type("IfcSpatialZone"))
for container in containers:
if obj := tool.Ifc.get_object(container):
- if collection := obj.BIMObjectProperties.collection:
+ if collection := tool.Blender.get_object_bim_props(obj).collection:
collection.hide_viewport = True
should_hide = False
else:
@@ -523,7 +523,7 @@ class SetContainerVisibility(bpy.types.Operator):
while queue:
container = queue.pop()
if obj := tool.Ifc.get_object(container):
- if collection := obj.BIMObjectProperties.collection:
+ if collection := tool.Blender.get_object_bim_props(obj).collection:
collection.hide_viewport = should_hide
if self.should_include_children:
queue.extend(ifcopenshell.util.element.get_parts(container))
diff --git a/src/bonsai/bonsai/bim/module/spatial/ui.py b/src/bonsai/bonsai/bim/module/spatial/ui.py
index 69d9068f85..3a4a771e01 100644
--- a/src/bonsai/bonsai/bim/module/spatial/ui.py
+++ b/src/bonsai/bonsai/bim/module/spatial/ui.py
@@ -251,7 +251,7 @@ class BIM_PT_grids(Panel):
bl_options = {"HEADER_LAYOUT_EXPAND"}
def draw(self, context):
- self.layout.row().operator("mesh.add_grid", icon="ADD", text="Add Grids")
+ self.layout.row().operator("bim.add_grid", icon="ADD", text="Add Grids")
def draw_header(self, context):
props = tool.Spatial.get_grid_props()
@@ -357,7 +357,7 @@ class BIM_UL_elements(UIList):
super().__init__(*args, **kwargs)
self.use_filter_show = True
- def draw_toggle(self, row: bpy.types.UILayout, is_expanded: bool, index: int):
+ def draw_toggle(self, row: bpy.types.UILayout, is_expanded: bool, index: int) -> None:
icon_id = "DISCLOSURE_TRI_DOWN" if is_expanded else "DISCLOSURE_TRI_RIGHT"
row.operator("bim.toggle_container_element", text="", emboss=False, icon=icon_id).element_index = index
diff --git a/src/bonsai/bonsai/bim/module/structural/load_decoration_data.py b/src/bonsai/bonsai/bim/module/structural/load_decoration_data.py
index 132c305ad1..dc05fab89c 100644
--- a/src/bonsai/bonsai/bim/module/structural/load_decoration_data.py
+++ b/src/bonsai/bonsai/bim/module/structural/load_decoration_data.py
@@ -28,6 +28,7 @@ import ifcopenshell.util.representation
import ifcopenshell.util.unit
import ifcopenshell.util.unit as ifcunit
import numpy as np
+import numpy.typing as npt
from mathutils import Vector
import bonsai.tool as tool
@@ -478,7 +479,7 @@ class ShaderInfo:
"""get the args to the point shader"""
location = np.array(location)
indices = []
- direction_dict = {
+ direction_dict: dict[str, tuple[npt.NDArray, ...]] = {
"fx": (np.array((1, 0, 0)), np.array((0, 1, 0)), np.array((0, 0, 1))),
"fy": (np.array((0, 1, 0)), np.array((1, 0, 0)), np.array((0, 0, 1))),
"fz": (np.array((0, 0, 1)), np.array((0, 1, 0)), np.array((1, 0, 0))),
diff --git a/src/bonsai/bonsai/bim/module/structural/operator.py b/src/bonsai/bonsai/bim/module/structural/operator.py
index 500dce4371..20b32c7a0c 100644
--- a/src/bonsai/bonsai/bim/module/structural/operator.py
+++ b/src/bonsai/bonsai/bim/module/structural/operator.py
@@ -42,14 +42,10 @@ class ShowLoads(bpy.types.Operator):
assert context.screen
if event.type == "F5":
LoadsDecorator.update()
- for area in context.screen.areas:
- if area.type == "VIEW_3D":
- area.tag_redraw()
+ tool.Blender.update_all_viewports(context)
if event.type == "ESC":
LoadsDecorator.uninstall()
- for area in context.screen.areas:
- if area.type == "VIEW_3D":
- area.tag_redraw()
+ tool.Blender.update_all_viewports(context)
return {"FINISHED"}
return {"PASS_THROUGH"}
@@ -69,9 +65,7 @@ class ShowLoads(bpy.types.Operator):
raise exc
context.window.cursor_modal_restore()
context.window_manager.modal_handler_add(self)
- for area in context.screen.areas:
- if area.type == "VIEW_3D":
- area.tag_redraw()
+ tool.Blender.update_all_viewports(context)
return {"RUNNING_MODAL"}
diff --git a/src/bonsai/bonsai/bim/module/structural/shader.py b/src/bonsai/bonsai/bim/module/structural/shader.py
index b9b5a5c7bc..9688ce9f0e 100644
--- a/src/bonsai/bonsai/bim/module/structural/shader.py
+++ b/src/bonsai/bonsai/bim/module/structural/shader.py
@@ -83,7 +83,7 @@ class DecorationShader:
PARALLEL DISTRIBUTED FORCE,
DISTRIBUTED MOMENT,
"""
- vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
+ vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments]
vert_out.smooth("VEC3", "forces")
vert_out.smooth("VEC3", "co")
@@ -203,7 +203,7 @@ class DecorationShader:
"""param: pattern: type of pattern
SINGLE FORCE,
SINGLE MOMENT"""
- vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
+ vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty: ignore[too-many-positional-arguments]
vert_out.smooth("VEC3", "co")
shader_info = gpu.types.GPUShaderCreateInfo()
@@ -253,7 +253,7 @@ class DecorationShader:
def get_planar_shader(self) -> gpu.types.GPUShader:
"""shader for planar loads"""
- vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
+ vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty: ignore[too-many-positional-arguments]
vert_out.smooth("VEC3", "co")
shader_info = gpu.types.GPUShaderCreateInfo()
diff --git a/src/bonsai/bonsai/bim/module/style/prop.py b/src/bonsai/bonsai/bim/module/style/prop.py
index 5d14bbd248..bd62021d65 100644
--- a/src/bonsai/bonsai/bim/module/style/prop.py
+++ b/src/bonsai/bonsai/bim/module/style/prop.py
@@ -102,7 +102,10 @@ def update_shading_styles(self: "BIMStylesProperties", context: bpy.types.Contex
def update_shader_graph(self: Union["Texture", "BIMStylesProperties"], context: bpy.types.Context) -> None:
- props = self.id_data.BIMStylesProperties if isinstance(self, Texture) else self
+ if isinstance(self, Texture):
+ props = tool.Style.get_style_props()
+ else:
+ props = self
if not props.update_graph:
return
diff --git a/src/bonsai/bonsai/bim/module/unit/ui.py b/src/bonsai/bonsai/bim/module/unit/ui.py
index 703c43c3d5..848425cd4e 100644
--- a/src/bonsai/bonsai/bim/module/unit/ui.py
+++ b/src/bonsai/bonsai/bim/module/unit/ui.py
@@ -123,7 +123,6 @@ class BIM_UL_units(UIList):
active_data,
active_propname,
) -> None:
- props = tool.Unit.get_unit_props()
if item:
icon = tool.Unit.get_icon_for_unit_class(item.ifc_class)
row = layout.row(align=True)
@@ -137,10 +136,10 @@ class BIM_UL_units(UIList):
op = row.operator("bim.assign_unit", text="", icon="KEYFRAME", emboss=False)
op.unit = item.ifc_definition_id
- if props.active_unit_id == item.ifc_definition_id:
+ if data.active_unit_id == item.ifc_definition_id:
row.operator("bim.edit_unit", text="", icon="CHECKMARK").unit = item.ifc_definition_id
row.operator("bim.disable_editing_unit", text="", icon="CANCEL")
- elif props.active_unit_id:
+ elif data.active_unit_id:
row.operator("bim.remove_unit", text="", icon="X").unit = item.ifc_definition_id
else:
op = row.operator("bim.enable_editing_unit", text="", icon="GREASEPENCIL")
diff --git a/src/bonsai/bonsai/bim/module/void/ui.py b/src/bonsai/bonsai/bim/module/void/ui.py
index 37415eb978..5fec870891 100644
--- a/src/bonsai/bonsai/bim/module/void/ui.py
+++ b/src/bonsai/bonsai/bim/module/void/ui.py
@@ -29,6 +29,8 @@ from bonsai.bim.module.void.data import BooleansData, VoidsData
if TYPE_CHECKING:
import bpy.stub_internal.rna_enums as rna_enums
+ from bonsai.bim.module.void.prop import Boolean
+
OPENING_ICON = "SELECT_SUBTRACT"
FILLING_ICON = "SELECT_INTERSECT"
@@ -175,7 +177,9 @@ class BIM_PT_booleans(Panel):
class BIM_UL_booleans(UIList):
- def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
+ def draw_item(
+ self, context, layout: bpy.types.UILayout, data, item: Boolean, icon, active_data, active_propname
+ ) -> None:
if item:
if item.operator == "DIFFERENCE":
icon = "SELECT_DIFFERENCE"
diff --git a/src/bonsai/bonsai/bim/operator.py b/src/bonsai/bonsai/bim/operator.py
index f332c91401..3216478132 100644
--- a/src/bonsai/bonsai/bim/operator.py
+++ b/src/bonsai/bonsai/bim/operator.py
@@ -70,7 +70,7 @@ class SetTab(bpy.types.Operator):
if context.area.spaces.active.search_filter:
return {"FINISHED"}
tool.Blender.setup_tabs()
- aprops = tool.Blender.get_area_props(context)
+ aprops = tool.Blender.get_active_area_props(context)
aprops.tab = self.tab
return {"FINISHED"}
@@ -85,7 +85,7 @@ class SwitchTab(bpy.types.Operator):
if context.area.spaces.active.search_filter:
return {"FINISHED"}
tool.Blender.setup_tabs()
- aprops = tool.Blender.get_area_props(context)
+ aprops = tool.Blender.get_active_area_props(context)
aprops.tab = aprops.alt_tab
return {"FINISHED"}
diff --git a/src/bonsai/bonsai/bim/prop.py b/src/bonsai/bonsai/bim/prop.py
index 61b916965e..e5d495c62a 100644
--- a/src/bonsai/bonsai/bim/prop.py
+++ b/src/bonsai/bonsai/bim/prop.py
@@ -59,7 +59,8 @@ def update_is_visible(self: "BIMTabVisibility", context: bpy.types.Context) -> N
def update_global_tab(self: "BIMTabProperties", context: bpy.types.Context) -> None:
tool.Blender.setup_tabs()
screen = context.id_data
- aprops = screen.BIMAreaProperties[screen.areas[:].index(context.area)]
+ assert isinstance(screen, bpy.types.Screen)
+ aprops = tool.Blender.get_area_props(screen)[screen.areas[:].index(context.area)]
aprops.tab = self.tab
diff --git a/src/bonsai/bonsai/bim/schema.py b/src/bonsai/bonsai/bim/schema.py
index 4ef96e72a7..15dfa87a59 100644
--- a/src/bonsai/bonsai/bim/schema.py
+++ b/src/bonsai/bonsai/bim/schema.py
@@ -16,19 +16,12 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see .
-from typing import TYPE_CHECKING
-
-import bpy
import ifcopenshell
import ifcopenshell.util.pset
+import bonsai
import bonsai.tool as tool
-if TYPE_CHECKING or bpy.app.version >= (5, 0, 0):
- import _bpy_restrict_state as bpy_restrict_state
-else:
- import bpy_restrict_state
-
class IfcSchema:
data_dir: str
@@ -62,7 +55,7 @@ class IfcSchema:
self.psetqto.get_by_name.cache_clear()
# During register we cannot access the context either way.
- if isinstance(bpy.context, bpy_restrict_state._RestrictContext):
+ if bonsai.is_registering():
return
for path in tool.Blender.get_data_dir_paths("pset", "*.ifc"):
self.psetqto.templates.append(ifcopenshell.open(path))
diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py
index 1d0e8742dd..09eee51401 100644
--- a/src/bonsai/bonsai/bim/ui.py
+++ b/src/bonsai/bonsai/bim/ui.py
@@ -787,7 +787,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
doc: DocPreferences
default_parameters: DefaultParameters
container_hide_show_isolate: bool
- mass_time_units_in_wizard: bool
chain_filter_with_set_operations: bool
save_metadata_blend_file: bool
metadata_blend_file_suffix: str
@@ -986,7 +985,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
def draw_extras_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
layout.prop(self, "container_hide_show_isolate")
- layout.prop(self, "mass_time_units_in_wizard")
row = layout.row(align=True)
row.prop(self, "chain_filter_with_set_operations")
row.operator("bim.open_uri", text="", icon="HELP").uri = "https://community.osarch.org/discussion/3270"
@@ -1031,8 +1029,7 @@ class BIM_PT_tabs(Panel):
def draw(self, context):
if not UIData.is_loaded:
UIData.load()
- aprops = tool.Blender.get_area_props(context)
- addon_prefs = tool.Blender.get_addon_preferences()
+ aprops = tool.Blender.get_active_area_props(context)
row = self.layout.row()
row.alignment = "CENTER"
@@ -1119,7 +1116,9 @@ class BIM_PT_tabs(Panel):
op = row.operator("bim.open_uri", text="", icon="QUESTION")
op.uri = "https://docs.bonsaibim.org/guides/troubleshooting.html#incompatible-blender-features"
- def draw_tab_entry(self, row, icon, tab_name, enabled=True, highlight=True):
+ def draw_tab_entry(
+ self, row: bpy.types.UILayout, icon: int | str, tab_name: str, enabled: bool = True, highlight: bool = True
+ ) -> None:
tab_entry = row.row(align=True)
if isinstance(icon, int):
tab_entry.operator("bim.set_tab", text="", emboss=highlight, icon_value=icon).tab = tab_name
diff --git a/src/bonsai/bonsai/core/covering.py b/src/bonsai/bonsai/core/covering.py
index a8c7a73f25..2ab0d39cc4 100644
--- a/src/bonsai/bonsai/core/covering.py
+++ b/src/bonsai/bonsai/core/covering.py
@@ -51,21 +51,11 @@ def add_instance_flooring_covering_from_cursor(
if isinstance(space_polygon, str):
return
- bm = spatial.get_bmesh_from_polygon(space_polygon, h=0, polygon_is_si=True)
- name = "Covering"
- mesh = spatial.get_named_mesh_from_bmesh(name=name, bmesh=bm)
-
- obj = spatial.get_named_obj_from_mesh(name, mesh)
-
+ obj = spatial.create_object("Covering")
spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj)
spatial.translate_obj_to_z_location(obj, z)
- points = spatial.get_2d_vertices_from_obj(obj)
- points = spatial.get_scaled_2d_vertices(points)
spatial.assign_type_to_obj(obj)
-
- spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points)
- body = spatial.get_body_representation(obj)
- spatial.regen_obj_representation(obj, body)
+ spatial.set_covering_representation_from_polygon(obj, space_polygon, polygon_is_si=True)
def add_instance_ceiling_covering_from_cursor(
@@ -95,21 +85,11 @@ def add_instance_ceiling_covering_from_cursor(
if isinstance(space_polygon, str):
return
- bm = spatial.get_bmesh_from_polygon(space_polygon, h=0, polygon_is_si=True)
- name = "Covering"
- mesh = spatial.get_named_mesh_from_bmesh(name=name, bmesh=bm)
-
- obj = spatial.get_named_obj_from_mesh(name, mesh)
-
+ obj = spatial.create_object("Covering")
spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj)
spatial.translate_obj_to_z_location(obj, z + ceiling_height)
- points = spatial.get_2d_vertices_from_obj(obj)
- points = spatial.get_scaled_2d_vertices(points)
spatial.assign_type_to_obj(obj)
-
- spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points)
- body = spatial.get_body_representation(obj)
- spatial.regen_obj_representation(obj, body)
+ spatial.set_covering_representation_from_polygon(obj, space_polygon, polygon_is_si=True)
def regen_selected_covering_object(root: type[tool.Root], spatial: type[tool.Spatial]) -> None:
@@ -127,19 +107,7 @@ def regen_selected_covering_object(root: type[tool.Root], spatial: type[tool.Spa
if isinstance(space_polygon, str):
return
- bm = spatial.get_bmesh_from_polygon(space_polygon, h=0, polygon_is_si=True)
-
- name = "Aux"
- mesh = spatial.get_named_mesh_from_bmesh(name=name, bmesh=bm)
- mesh = spatial.get_transformed_mesh_from_local_to_global(mesh)
- obj = spatial.get_named_obj_from_mesh(name, mesh)
-
- points = spatial.get_2d_vertices_from_obj(obj)
- points = spatial.get_scaled_2d_vertices(points)
-
- spatial.assign_swept_area_outer_curve_from_2d_vertices(active_obj, vertices=points)
- body = spatial.get_body_representation(active_obj)
- spatial.regen_obj_representation(active_obj, body)
+ spatial.set_covering_representation_from_polygon(active_obj, space_polygon, polygon_is_si=True)
# TODO CHECK IF IT IS POSSIBLE TO CREATE ONLY ONE CORE FUNCTION FOR _FROM_WALLS
@@ -151,22 +119,13 @@ def add_instance_flooring_coverings_from_walls(root: type[tool.Root], spatial: t
union = spatial.get_union_shape_from_selected_objects()
for i, linear_ring in enumerate(union.interiors):
poly = spatial.get_buffered_poly_from_linear_ring(linear_ring)
- bm = spatial.get_bmesh_from_polygon(poly, h=0, polygon_is_si=False)
name = "Covering" + str(i)
- obj = spatial.get_named_obj_from_bmesh(name, bmesh=bm)
-
- spatial.set_obj_origin_to_bboxcenter(obj)
+ obj = spatial.create_object(name)
+ spatial.set_obj_origin_to_polygon_center(obj, poly, polygon_is_si=False)
spatial.translate_obj_to_z_location(obj, z)
-
- points = spatial.get_2d_vertices_from_obj(obj)
- points = spatial.get_scaled_2d_vertices(points)
-
spatial.assign_type_to_obj(obj)
-
- spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points)
- body = spatial.get_body_representation(obj)
- spatial.regen_obj_representation(obj, body)
+ spatial.set_covering_representation_from_polygon(obj, poly, polygon_is_si=False)
def add_instance_ceiling_coverings_from_walls(
@@ -179,22 +138,13 @@ def add_instance_ceiling_coverings_from_walls(
union = spatial.get_union_shape_from_selected_objects()
for i, linear_ring in enumerate(union.interiors):
poly = spatial.get_buffered_poly_from_linear_ring(linear_ring)
- bm = spatial.get_bmesh_from_polygon(poly, h=0, polygon_is_si=False)
name = "Covering" + str(i)
- obj = spatial.get_named_obj_from_bmesh(name, bmesh=bm)
-
- spatial.set_obj_origin_to_bboxcenter(obj)
+ obj = spatial.create_object(name)
+ spatial.set_obj_origin_to_polygon_center(obj, poly, polygon_is_si=False)
spatial.translate_obj_to_z_location(obj, z)
-
- points = spatial.get_2d_vertices_from_obj(obj)
- points = spatial.get_scaled_2d_vertices(points)
-
spatial.assign_type_to_obj(obj)
-
- spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points)
- body = spatial.get_body_representation(obj)
- spatial.regen_obj_representation(obj, body)
+ spatial.set_covering_representation_from_polygon(obj, poly, polygon_is_si=False)
class NoDefaultContainer(Exception):
diff --git a/src/bonsai/bonsai/core/drawing.py b/src/bonsai/bonsai/core/drawing.py
index 42b0f77fbb..5db2ced03e 100644
--- a/src/bonsai/bonsai/core/drawing.py
+++ b/src/bonsai/bonsai/core/drawing.py
@@ -432,10 +432,7 @@ def update_drawing_name(
if drawing_tool.get_name(drawing) != name:
ifc.run("attribute.edit_attributes", product=drawing, attributes={"Name": name})
- # Update the camera object name
- camera = ifc.get_object(drawing)
- if camera and camera.name != name:
- camera.name = name
+ drawing_tool.set_camera_name(drawing, name)
group = drawing_tool.get_drawing_group(drawing)
if drawing_tool.get_name(group) != name:
diff --git a/src/bonsai/bonsai/core/ifcgit.py b/src/bonsai/bonsai/core/ifcgit.py
index fb23652ebc..f14dda6cbb 100644
--- a/src/bonsai/bonsai/core/ifcgit.py
+++ b/src/bonsai/bonsai/core/ifcgit.py
@@ -56,42 +56,45 @@ def discard_uncommitted(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc]) -> None:
ifcgit.load_project(path_ifc)
-def commit_changes(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], repo: git.Repo) -> None:
+def commit_changes(
+ ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], commit_message: str, new_branch_name: str = ""
+) -> None:
"""Commit and create new branches as required"""
path_ifc = ifc.get_path()
- if repo.head.is_detached:
- ifcgit.git_commit(path_ifc)
- ifcgit.create_new_branch()
+ if ifcgit.is_head_detached():
+ ifcgit.git_commit(path_ifc, commit_message)
+ ifcgit.create_new_branch(new_branch_name)
else:
- ifcgit.checkout_new_branch(path_ifc)
- ifcgit.git_commit(path_ifc)
+ if new_branch_name:
+ ifcgit.checkout_new_branch(path_ifc, new_branch_name)
+ ifcgit.git_commit(path_ifc, commit_message)
-def add_tag(ifcgit: type[tool.IfcGit], repo: git.Repo) -> None:
- ifcgit.add_tag(repo)
+def add_tag(ifcgit: type[tool.IfcGit], repo: git.Repo, hexsha: str, tag_name: str, tag_message: str = "") -> None:
+ ifcgit.add_tag(repo, hexsha, tag_name, tag_message)
def delete_tag(ifcgit: type[tool.IfcGit], repo: git.Repo, tag_name: git.TagReference) -> None:
ifcgit.delete_tag(repo, tag_name)
-def add_remote(ifcgit: type[tool.IfcGit], repo: git.Repo) -> None:
- ifcgit.add_remote(repo)
+def add_remote(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str, remote_url: str) -> None:
+ ifcgit.add_remote(repo, remote_name, remote_url)
-def delete_remote(ifcgit: type[tool.IfcGit], repo: git.Repo) -> None:
- ifcgit.delete_remote(repo)
+def delete_remote(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str) -> None:
+ ifcgit.delete_remote(repo, remote_name)
def push(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str, operator: bpy.types.Operator) -> None:
- error_message = ifcgit.push(repo, remote_name, repo.active_branch.name)
+ error_message = ifcgit.push(repo, remote_name, ifcgit.get_active_branch_name())
if error_message:
operator.report({"ERROR"}, error_message)
-def refresh_revision_list(ifcgit: type[tool.IfcGit], repo: git.Repo, ifc: type[tool.Ifc]) -> None:
- if repo.heads:
+def refresh_revision_list(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc]) -> None:
+ if ifcgit.repo_has_commits():
ifcgit.refresh_revision_list(ifc.get_path())
@@ -125,10 +128,32 @@ def switch_revision(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc]) -> None:
ifcgit.decolourise()
-def merge_branch(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy.types.Operator) -> None:
+def merge_branch(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy.types.Operator) -> bool | None:
path_ifc = ifc.get_path()
ifcgit.config_ifcmerge()
- ifcgit.execute_merge(path_ifc, operator)
+
+ branch_name = ifcgit.get_selected_branch()
+ if branch_name is None:
+ return
+
+ mergetool = ifcgit.get_merge_tool(branch_name)
+ merge_result = ifcgit.git_merge(branch_name)
+
+ if merge_result == "error":
+ operator.report({"ERROR"}, "Unknown IFC Merge failure")
+ return False
+ elif merge_result == "conflict":
+ error = ifcgit.git_mergetool(mergetool)
+ if error:
+ ifcgit.git_merge_abort()
+ operator.report({"ERROR"}, "IFC Merge failed:" + error)
+ return False
+ ifcgit.commit_merge(path_ifc)
+
+ ifcgit.set_display_branch()
+ ifcgit.load_project(path_ifc)
+ ifcgit.refresh_revision_list(path_ifc)
+ ifcgit.decolourise()
def entity_log(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], step_id: int, operator: bpy.types.Operator) -> None:
@@ -145,5 +170,9 @@ def install_git(ifcgit: type[tool.IfcGit], operator: bpy.types.Operator) -> None
print("install_git() not implemented")
+def fetch(ifcgit: type[tool.IfcGit], remote_name: str) -> None:
+ ifcgit.fetch(remote_name)
+
+
def run_git_diff(ifcgit: type[tool.IfcGit], operator: bpy.types.Operator, save_to_temp: bool) -> None:
ifcgit.run_git_diff(operator, save_to_temp)
diff --git a/src/bonsai/bonsai/core/material.py b/src/bonsai/bonsai/core/material.py
index 65c5955603..0a08f5e0bf 100644
--- a/src/bonsai/bonsai/core/material.py
+++ b/src/bonsai/bonsai/core/material.py
@@ -113,6 +113,7 @@ def assign_material(
material_type: Union[str, None],
objects: list[bpy.types.Object],
material: Optional[ifcopenshell.entity_instance] = None,
+ should_auto_assign_usage: bool = True,
) -> None:
"""Assign material to the provided objects.
@@ -121,12 +122,18 @@ def assign_material(
"""
material_type = material_type or material_tool.get_object_ui_material_type()
material = material or material_tool.get_object_ui_active_material()
+ can_be_usage = should_auto_assign_usage and material_type in ("IfcMaterialLayerSet", "IfcMaterialProfileSet")
for obj in objects:
element = ifc.get_entity(obj)
if not element:
continue
- ifc.run("material.assign_material", products=[element], type=material_type, material=material)
+ if can_be_usage and not material_tool.is_type_product(element):
+ element_material_type = material_type + "Usage"
+ else:
+ element_material_type = material_type
+
+ ifc.run("material.assign_material", products=[element], type=element_material_type, material=material)
assigned_material = material_tool.get_material(element)
assert assigned_material # Type checker.
@@ -136,7 +143,9 @@ def assign_material(
material_tool.add_material_to_set(material_set=material, material=default_material)
elif material_tool.is_a_material_set(assigned_material):
material_tool.add_material_to_set(material_set=assigned_material, material=material)
- material_tool.ensure_material_assigned(elements=[element], material_type=material_type, material=material)
+ material_tool.ensure_material_assigned(
+ elements=[element], material_type=element_material_type, material=material
+ )
def unassign_material(ifc: type[tool.Ifc], material_tool: type[tool.Material], objects: list[bpy.types.Object]) -> None:
diff --git a/src/bonsai/bonsai/core/model.py b/src/bonsai/bonsai/core/model.py
index 7b17d5de5d..e975505381 100644
--- a/src/bonsai/bonsai/core/model.py
+++ b/src/bonsai/bonsai/core/model.py
@@ -109,7 +109,7 @@ def align_walls(
align_type: AlignType,
):
reference_obj = blender.get_active_object(is_selected=True)
- if not (e := ifc.get_entity(reference_obj) or not model.get_usage_type(e) == "LAYER2"):
+ if not reference_obj or not (e := ifc.get_entity(reference_obj)) or not model.get_usage_type(e) == "LAYER2":
reference_obj = None
objs = [
o
@@ -159,48 +159,6 @@ def extend_wall_to_slab(
model.reload_body_representation(wall_objs)
-def join_walls_TZ(
- ifc: type[tool.Ifc],
- blender: type[tool.Blender],
- geometry: type[tool.Geometry],
- joiner: DumbWallJoiner,
- model: type[tool.Model],
-) -> None:
- selected_objs = [
- o
- for o in blender.get_selected_objects()
- if (e := ifc.get_entity(o)) and model.get_usage_type(e) in ("LAYER2", "LAYER3")
- ]
- if len(selected_objs) < 2:
- raise RequireAtLeastTwoLayeredElements(
- "Two or more vertically or horizontally layered elements must be selected to connect their paths together"
- )
-
- for obj in selected_objs:
- geometry.clear_scale(obj)
-
- elements = [ifc.get_entity(o) for o in blender.get_selected_objects()]
- layer2_elements = []
- layer3_elements = []
- for element in elements:
- usage = model.get_usage_type(element)
- if usage == "LAYER2":
- layer2_elements.append(element)
- elif usage == "LAYER3":
- layer3_elements.append(element)
- if layer3_elements:
- target = ifc.get_object(layer3_elements[0])
- for element in layer2_elements:
- joiner.join_Z(ifc.get_object(element), target)
- else:
- if not (active_obj := blender.get_active_object()):
- active_obj = selected_objs[0]
- for obj in selected_objs:
- if obj == active_obj:
- continue
- joiner.join_T(obj, active_obj)
-
-
class RequireTwoWallsError(Exception):
pass
diff --git a/src/bonsai/bonsai/core/spatial.py b/src/bonsai/bonsai/core/spatial.py
index 5086a8a872..5ce6d7c253 100644
--- a/src/bonsai/bonsai/core/spatial.py
+++ b/src/bonsai/bonsai/core/spatial.py
@@ -219,13 +219,8 @@ def generate_space(
else:
assert space_polygon
- bm = spatial.get_bmesh_from_polygon(space_polygon, h=h, polygon_is_si=True)
-
- mesh = spatial.get_named_mesh_from_bmesh(name="Space", bmesh=bm)
-
if element and element.is_a("IfcSpace"):
- mesh = spatial.get_transformed_mesh_from_local_to_global(mesh)
- spatial.edit_active_space_obj_from_mesh(mesh)
+ 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:
@@ -233,12 +228,13 @@ def generate_space(
else:
name = "Space"
- obj = spatial.get_named_obj_from_mesh(name, mesh)
+ obj = spatial.create_object(name)
spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj)
spatial.translate_obj_to_z_location(obj, z)
spatial.assign_ifcspace_class_to_obj(obj)
element = ifc.get_entity(obj)
+ spatial.set_space_representation_from_polygon(obj, element, space_polygon, h, polygon_is_si=True)
if relating_type:
spatial.assign_relating_type_to_element(ifc, type, element, relating_type)
@@ -257,16 +253,16 @@ def generate_spaces_from_walls(
for i, linear_ring in enumerate(union.interiors):
poly = spatial.get_buffered_poly_from_linear_ring(linear_ring)
- bm = spatial.get_bmesh_from_polygon(poly, h, polygon_is_si=False)
-
name = "Space" + str(i)
- obj = spatial.get_named_obj_from_bmesh(name, bmesh=bm)
-
- spatial.set_obj_origin_to_bboxcenter_and_zero_elevation(obj)
+ obj = spatial.create_object(name)
+ spatial.set_obj_origin_to_polygon_center(obj, poly, polygon_is_si=False)
spatial.translate_obj_to_z_location(obj, z)
spatial.assign_ifcspace_class_to_obj(obj)
+ element = ifc.get_entity(obj)
+ spatial.set_space_representation_from_polygon(obj, element, poly, h, polygon_is_si=False)
+
def toggle_space_visibility(ifc: type[tool.Ifc], spatial: type[tool.Spatial]) -> None:
model = ifc.get()
diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py
index a0b6290b12..910ed79627 100644
--- a/src/bonsai/bonsai/core/tool.py
+++ b/src/bonsai/bonsai/core/tool.py
@@ -99,6 +99,7 @@ class Blender:
def get_object_bounding_box(cls, obj): pass
def get_selected_objects(cls, include_active=False): pass
def get_viewport_context(cls): pass
+ def operator_idname_to_py(cls, idname): pass
def is_ifc_class_active(cls, ifc_class): pass
def is_ifc_object(cls, obj): pass
def remove_object(cls, obj): pass
@@ -348,7 +349,10 @@ class Drawing:
def enable_editing_text(cls, obj): pass
def ensure_unique_drawing_name(cls, name): pass
def ensure_unique_identification(cls, identification): pass
+ def export_font_size(cls, obj): pass
+ def export_symbol(cls, obj): pass
def export_text_literal_attributes(cls, obj): pass
+ def export_wrap_length(cls, obj): pass
def generate_drawing_matrix(cls, target_view, location_hint): pass
def generate_drawing_name(cls, target_view, location_hint): pass
def generate_reference_attributes(cls, reference, **attributes): pass
@@ -401,6 +405,7 @@ class Drawing:
def run_root_assign_class(cls, obj=None, ifc_class=None, predefined_type=None, should_add_representation=True, context=None, ifc_representation_class=None): pass
def run_type_assign_type(cls, element=None, relating_type=None): pass
def select_assigned_product(cls, drawing): pass
+ def set_camera_name(cls, drawing, name): pass
def set_drawing_collection_name(cls, drawing, collection): pass
def set_name(cls, element, name): pass
def setup_annotation_object(cls, obj, object_type): pass
@@ -530,6 +535,56 @@ class Ifc:
def get_all_element_occurrences(cls, element): pass
+@interface
+class IfcGit:
+ def add_file_to_repo(cls, repo, path_file): pass
+ def add_remote(cls, repo, remote_name, remote_url): pass
+ def add_tag(cls, repo, hexsha, tag_name, tag_message): pass
+ def branches_by_hexsha(cls, repo): pass
+ def checkout_new_branch(cls, path_file, branch_name): pass
+ def clear_commits_list(cls): pass
+ def clone_repo(cls, remote_url, local_folder): pass
+ def colourise(cls, step_ids): pass
+ def config_ifcmerge(cls): pass
+ def create_new_branch(cls, branch_name): pass
+ def decolourise(cls): pass
+ def delete_remote(cls, repo, remote_name): pass
+ def delete_tag(cls, repo, tag_name): pass
+ def dos2unix(cls, path_file): pass
+ def commit_merge(cls, path_ifc): pass
+ def entity_log(cls, path_ifc, step_id): pass
+ def fetch(cls, remote_name): pass
+ def get_commits_list(cls, path_ifc, lookup): pass
+ def get_merge_tool(cls, branch_name): pass
+ def get_selected_branch(cls): pass
+ def git_merge(cls, branch_name): pass
+ def git_merge_abort(cls): pass
+ def git_mergetool(cls, mergetool): pass
+ def set_display_branch(cls): pass
+ def get_active_branch_name(cls): pass
+ def get_ifcgit_props(cls): pass
+ def get_modified_step_ids(cls, step_ids): pass
+ def get_path_dir(cls, path_ifc): pass
+ def get_revisions_step_ids(cls): pass
+ def is_head_detached(cls): pass
+ def repo_has_commits(cls): pass
+ def git_checkout(cls, path_file): pass
+ def git_commit(cls, path_file, commit_message): pass
+ def ifc_diff_ids(cls, repo, hash_a, hash_b, path_ifc): pass
+ def init_repo(cls, path_dir): pass
+ def install_git_windows(cls, operator): pass
+ def is_valid_ref_format(cls, string): pass
+ def load_anyifc(cls, repo): pass
+ def load_project(cls, path_ifc): pass
+ def push(cls, repo, remote_name, branch_name): pass
+ def refresh_revision_list(cls, path_ifc): pass
+ def repo_from_path(cls, path): pass
+ def run_git_diff(cls, operator, save_to_temp): pass
+ def switch_to_revision_item(cls): pass
+ def tags_by_hexsha(cls, repo): pass
+ def update_step_ids(cls, step_ids, modified_step_ids): pass
+
+
@interface
class Layer:
pass
@@ -579,6 +634,7 @@ class Material:
def import_material_definitions(cls, material_type: str): pass
def is_a_flow_segment(cls, element): pass
def is_a_material_set(cls, material): pass
+ def is_type_product(cls, element): pass
def is_editing_materials(cls): pass
def is_material_used_in_sets(cls, material): pass
def load_material_attributes(cls, material): pass
@@ -618,7 +674,10 @@ class Model:
def import_rectangle(cls, obj, position, profile): pass
def load_openings(cls, openings): pass
def purge_scene_openings(cls): pass
+ def recalculate_walls(cls, objs): pass
def regenerate_array(cls, parent, data): pass
+ def regenerate_profile(cls, obj): pass
+ def regenerate_slab(cls, obj): pass
def reload_body_representation(cls, obj_or_objects): pass
def replace_object_ifc_representation(cls, ifc_file, ifc_context, obj, new_representation): pass
@@ -990,14 +1049,12 @@ class Spatial:
def get_purged_inner_holes_poly(cls, union_geom, min_area): pass
def get_poly_valid_interior_list(cls, poly, min_area, interiors_list): pass
def get_buffered_poly_from_linear_ring(cls, linear_ring): pass
- def get_bmesh_from_polygon(cls, poly, h, polygon_is_si=False): pass
- def get_named_obj_from_bmesh(cls, name, bmesh): pass
- def get_named_obj_from_mesh(cls, name, mesh): pass
- def get_named_mesh_from_bmesh(cls, name, bmesh): pass
- def get_transformed_mesh_from_local_to_global(cls, mesh): pass
- def edit_active_space_obj_from_mesh(cls, mesh): pass
- def set_obj_origin_to_bboxcenter(cls, obj): pass
- def set_obj_origin_to_bboxcenter_and_zero_elevation(cls, obj): pass
+ def get_2d_vertices_from_polygon(cls, poly, obj, polygon_is_si=True): pass
+ def set_extrusion_representation_from_polygon(cls, obj, element, poly, depth_ifc, polygon_is_si=True): pass
+ def set_space_representation_from_polygon(cls, obj, element, poly, h, polygon_is_si=True): pass
+ def set_covering_representation_from_polygon(cls, obj, poly, polygon_is_si=True): pass
+ def create_object(cls, name): pass
+ def set_obj_origin_to_polygon_center(cls, obj, poly, polygon_is_si=True): pass
def set_obj_origin_to_cursor_position_and_zero_elevation(cls, obj): pass
def get_selected_objects(cls): pass
def get_active_obj(cls): pass
@@ -1005,14 +1062,9 @@ class Spatial:
def get_active_obj_height(cls): pass
def get_relating_type_id(cls): pass
def translate_obj_to_z_location(cls, obj, z): pass
- def get_2d_vertices_from_obj(cls, obj): pass
- def get_scaled_2d_vertices(cls, points): pass
- def assign_swept_area_outer_curve_from_2d_vertices(cls, obj, vertices): pass
- def get_body_representation(cls, obj): pass
def assign_ifcspace_class_to_obj(cls, obj): pass
def assign_type_to_obj(cls, obj): pass
def assign_relating_type_to_element(cls, ifc, type, element, relating_type): pass
- def regen_obj_representation(cls, obj, body): pass
def toggle_spaces_visibility_wired_and_textured(cls, spaces): pass
def toggle_hide_spaces(cls, spaces): pass
def set_default_container(cls, container): pass
@@ -1117,6 +1169,8 @@ class Type:
def get_representation_context(cls, representation): pass
def get_type_occurrences(cls, element_type): pass
def has_material_usage(cls, element): pass
+ def record_material_usage_attributes(cls, element): pass
+ def restore_material_usage_attributes(cls, element, usage_attributes): pass
def run_geometry_add_representation(cls, obj=None, context=None, ifc_representation_class=None, profile_set_usage=None): pass
def run_geometry_switch_representation(cls, obj=None, representation=None): pass
diff --git a/src/bonsai/bonsai/tool/aggregate.py b/src/bonsai/bonsai/tool/aggregate.py
index 43b155a5aa..1f7f601862 100644
--- a/src/bonsai/bonsai/tool/aggregate.py
+++ b/src/bonsai/bonsai/tool/aggregate.py
@@ -49,21 +49,40 @@ class Aggregate(bonsai.core.tool.Aggregate):
related_object = tool.Ifc.get_entity(related_obj)
if not relating_object or not related_object:
return False
+ if relating_object == related_object:
+ return False
+
+ is_compatible_class = False
if (relating_object.is_a("IfcElement") or relating_object.is_a("IfcElementType")) and related_object.is_a(
"IfcElement"
):
- return True
- if tool.Ifc.get_schema() == "IFC2X3":
+ is_compatible_class = True
+ elif tool.Ifc.get_schema() == "IFC2X3":
if relating_object.is_a("IfcSpatialStructureElement") and related_object.is_a("IfcSpatialStructureElement"):
- return True
- if relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialStructureElement"):
- return True
+ is_compatible_class = True
+ elif relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialStructureElement"):
+ is_compatible_class = True
else:
if relating_object.is_a("IfcSpatialElement") and related_object.is_a("IfcSpatialElement"):
- return True
- if relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialElement"):
- return True
- return False
+ is_compatible_class = True
+ elif relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialElement"):
+ is_compatible_class = True
+
+ if not is_compatible_class:
+ return False
+
+ # Prevent cyclic references: walk up the full hierarchy from the
+ # proposed parent and reject if we encounter the proposed child.
+ ancestor = ifcopenshell.util.element.get_parent(relating_object)
+ seen = {relating_object}
+ while ancestor:
+ if ancestor == related_object:
+ return False
+ if ancestor in seen:
+ break
+ seen.add(ancestor)
+ ancestor = ifcopenshell.util.element.get_parent(ancestor)
+ return True
@classmethod
def has_physical_body_representation(cls, element: ifcopenshell.entity_instance) -> bool:
diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py
index 338c9da70c..6c759027f2 100644
--- a/src/bonsai/bonsai/tool/blender.py
+++ b/src/bonsai/bonsai/tool/blender.py
@@ -74,7 +74,13 @@ if TYPE_CHECKING:
BIMSolarProperties,
RadianceExporterProperties,
)
- from bonsai.bim.prop import BIMObjectProperties, BIMProperties
+ from bonsai.bim.prop import (
+ BIMAreaProperties,
+ BIMCollectionProperties,
+ BIMObjectProperties,
+ BIMProperties,
+ BIMTabProperties,
+ )
T = TypeVar("T")
@@ -138,17 +144,20 @@ class Blender(bonsai.core.tool.Blender):
space.region_3d.view_perspective = "CAMERA"
@classmethod
- def get_area_props(cls, context: bpy.types.Context) -> bpy.types.PropertyGroup:
+ def get_active_area_props(cls, context: bpy.types.Context) -> BIMAreaProperties | BIMTabProperties:
+ FULLSCREEN_SUFFIX = "-nonnormal" # Ctrl-space temporary fullscreen
+ assert (screen := context.screen)
try:
- if context.screen.name.endswith("-nonnormal"): # Ctrl-space temporary fullscreen
- screen = bpy.data.screens[context.screen.name.removesuffix("-nonnormal")]
+ if screen.name.endswith(FULLSCREEN_SUFFIX):
+ screen = bpy.data.screens[screen.name.removesuffix(FULLSCREEN_SUFFIX)]
# The original area object has its type changed to "EMPTY" apparently
index = [a.type for a in screen.areas].index("EMPTY")
- return screen.BIMAreaProperties[index]
- return context.screen.BIMAreaProperties[context.screen.areas[:].index(context.area)]
+ return cls.get_area_props(screen)[index]
+ assert (area := context.area)
+ return cls.get_area_props(screen)[screen.areas[:].index(area)]
except IndexError:
# Fallback in case areas aren't setup yet.
- return context.screen.BIMTabProperties
+ return cls.get_tab_props(screen)
@classmethod
def set_active_object(cls, obj: bpy.types.Object) -> None:
@@ -165,15 +174,16 @@ class Blender(bonsai.core.tool.Blender):
def setup_tabs(cls) -> None:
# https://blender.stackexchange.com/questions/140644/how-can-make-the-state-of-a-boolean-property-relative-to-the-3d-view-area
for screen in bpy.data.screens:
- if len(screen.BIMAreaProperties) == 20:
+ area_props = cls.get_area_props(screen)
+ if len(area_props) == 20:
continue
- screen.BIMAreaProperties.clear()
+ area_props.clear()
for i in range(20): # 20 is an arbitrary value of split areas
- screen.BIMAreaProperties.add()
+ area_props.add()
@classmethod
def should_show_panel(cls, context: bpy.types.Context, tab: str, panel: str) -> bool:
- aprops = cls.get_area_props(context)
+ aprops = cls.get_active_area_props(context)
if aprops.path_from_id() == "BIMAreaProperties" and context.area.spaces.active.search_filter:
return True
if (is_bookmark_tab := aprops.tab == "BOOKMARK") or aprops.tab == tab:
@@ -185,6 +195,7 @@ class Blender(bonsai.core.tool.Blender):
return True
elif panel_visibility.is_visible:
return True
+ return False
@classmethod
def is_default_scene(cls) -> bool:
@@ -330,15 +341,27 @@ class Blender(bonsai.core.tool.Blender):
@classmethod
def get_view3d_area(cls) -> Union[bpy.types.Area, None]:
- for window in bpy.context.window_manager.windows:
+ assert (wm := bpy.context.window_manager)
+ for window in wm.windows:
for area in window.screen.areas:
if area.type == "VIEW_3D":
return area
+ @classmethod
+ def operator_idname_to_py(cls, idname: str) -> str:
+ """Convert a Blender internal operator idname to its Python equivalent.
+
+ Example: ``MESH_OT_primitive_cube_add`` -> ``mesh.primitive_cube_add``
+ """
+ module, func = idname.split("_OT_", 1)
+ return f"{module.lower()}.{func}"
+
@classmethod
def get_view3d_space(cls) -> Union[bpy.types.SpaceView3D, None]:
if area := cls.get_view3d_area():
- return area.spaces.active
+ space = area.spaces.active
+ assert isinstance(space, bpy.types.SpaceView3D)
+ return space
@classmethod
def get_blender_prop_default_value(cls, props: bpy.types.bpy_struct, prop_name: str) -> Any:
@@ -459,6 +482,14 @@ class Blender(bonsai.core.tool.Blender):
def update_viewport(cls) -> None:
cls.get_viewport_context()["area"].tag_redraw()
+ @classmethod
+ def update_all_viewports(cls, context: bpy.types.Context | None = None) -> None:
+ context = context or bpy.context
+ assert context.screen
+ for area in context.screen.areas:
+ if area.type == "VIEW_3D":
+ area.tag_redraw()
+
@classmethod
def force_depsgraph_update(cls) -> None:
"""useful if you need to trigger callbacks like `depsgraph_update_pre`"""
@@ -1489,7 +1520,7 @@ class Blender(bonsai.core.tool.Blender):
def override_scene_panel(cls, original_panel: bpy.types.Panel) -> None:
@classmethod
def poll_check_blender_tab(cls, context):
- aprops = tool.Blender.get_area_props(context)
+ aprops = tool.Blender.get_active_area_props(context)
if aprops.path_from_id() == "BIMAreaProperties" and context.area.spaces.active.search_filter:
return True
return aprops.tab == "BLENDER"
@@ -1829,6 +1860,18 @@ class Blender(bonsai.core.tool.Blender):
assert (scene := bpy.context.scene)
return scene.BIMProperties # pyright: ignore[reportAttributeAccessIssue]
+ @classmethod
+ def get_area_props(cls, screen: bpy.types.Screen) -> bpy.types.bpy_prop_collection_idprop[BIMAreaProperties]:
+ return screen.BIMAreaProperties # pyright: ignore[reportAttributeAccessIssue]
+
+ @classmethod
+ def get_tab_props(cls, screen: bpy.types.Screen) -> BIMTabProperties:
+ return screen.BIMTabProperties # pyright: ignore[reportAttributeAccessIssue]
+
+ @classmethod
+ def get_collection_props(cls, collection: bpy.types.Collection) -> BIMCollectionProperties:
+ return collection.BIMCollectionProperties # pyright: ignore[reportAttributeAccessIssue]
+
@classmethod
def get_object_bim_props(cls, obj: bpy.types.Object) -> BIMObjectProperties:
return obj.BIMObjectProperties # pyright: ignore[reportAttributeAccessIssue]
@@ -1882,19 +1925,21 @@ class Blender(bonsai.core.tool.Blender):
@classmethod
def clear_undo_history(cls) -> None:
"""Clears the Blender history, Bonsai history, and IfcOpenShell history"""
- old_undo_steps = bpy.context.preferences.edit.undo_steps
- bpy.context.preferences.edit.undo_steps = 2
+ assert (preferences := bpy.context.preferences)
+ old_undo_steps = preferences.edit.undo_steps
+ preferences.edit.undo_steps = 2
for i in range(3):
bpy.ops.ed.undo_push(message="Undo history cleared")
- bpy.context.preferences.edit.undo_steps = old_undo_steps
+ preferences.edit.undo_steps = old_undo_steps
tool.Ifc.clear_history()
old_history_size = tool.Ifc.get().history_size
tool.Ifc.get().set_history_size(0)
tool.Ifc.get().set_history_size(old_history_size)
@classmethod
- def get_unit_scale(cls):
- unit_length = bpy.context.scene.unit_settings.length_unit
+ def get_unit_scale(cls) -> float:
+ assert (scene := bpy.context.scene)
+ unit_length = scene.unit_settings.length_unit
unit_scale = 1.0
if unit_length == "CENTIMETERS":
unit_scale = 0.01
diff --git a/src/bonsai/bonsai/tool/bsdd.py b/src/bonsai/bonsai/tool/bsdd.py
index 47c948abcc..387474b81f 100644
--- a/src/bonsai/bonsai/tool/bsdd.py
+++ b/src/bonsai/bonsai/tool/bsdd.py
@@ -32,14 +32,28 @@ import bonsai.core.tool
import bonsai.tool as tool
if TYPE_CHECKING:
+ from bsdd.bsdd import ClassContractV1, ClassPropertyContractV1, PropertyContractV5
+
from bonsai.bim.module.bsdd.prop import BIMBSDDProperties, BSDDDictionary
class Bsdd(bonsai.core.tool.Bsdd):
- identifier_url = "https://identifier.buildingsmart.org"
+ default_identifier_url = "https://identifier.buildingsmart.org"
+ default_api_url = "https://api.bsdd.buildingsmart.org/api/"
client = bsdd.Client()
- bsdd_classes: dict[str, dict] = {}
- bsdd_properties: dict[str, dict] = {}
+ bsdd_classes: dict[str, ClassContractV1] = {}
+ bsdd_properties: dict[str, ClassPropertyContractV1 | PropertyContractV5] = {}
+
+ @classmethod
+ def identifier_url(cls) -> str:
+ """Derives the identifier base URL from the current client baseurl.
+ Falls back to the standard bSDD identifier URL when using the default API."""
+ if cls.client.baseurl == cls.default_api_url:
+ return cls.default_identifier_url
+ from urllib.parse import urlparse
+
+ parsed = urlparse(cls.client.baseurl)
+ return f"{parsed.scheme}://{parsed.netloc}"
@classmethod
def get_bsdd_props(cls) -> BIMBSDDProperties:
@@ -255,7 +269,8 @@ class Bsdd(bonsai.core.tool.Bsdd):
@classmethod
def get_bsdd_property(cls, uri: str) -> dict:
if not (bsdd_property := cls.bsdd_properties.get(uri, {})):
- bsdd_property = cls.client.get_property(uri, include_classes=True)
+ # Cache miss occurs for keyword search mode, for classes cache is prepopulated.
+ bsdd_property = cls.client.get_property(uri)
cls.bsdd_properties[uri] = bsdd_property
return bsdd_property
@@ -269,7 +284,7 @@ class Bsdd(bonsai.core.tool.Bsdd):
for obj in tool.Blender.get_selected_objects(include_active=True):
if element := tool.Ifc.get_entity(obj):
for reference in ifcopenshell.util.classification.get_references(element):
- if (uri := reference.Location) and uri.startswith(cls.identifier_url):
+ if (uri := reference.Location) and uri.startswith(cls.identifier_url()):
classes.add((reference[1] or reference[2] or "Unnamed", uri))
dictionary_uris = (
@@ -383,7 +398,7 @@ class Bsdd(bonsai.core.tool.Bsdd):
def get_applicable_psets(cls, element: ifcopenshell.entity_instance):
uris = set()
for reference in ifcopenshell.util.classification.get_references(element):
- if (uri := reference.Location) and uri.startswith(cls.identifier_url):
+ if (uri := reference.Location) and uri.startswith(cls.identifier_url()):
uris.add(uri)
psets = set()
for uri in uris:
@@ -399,7 +414,7 @@ class Bsdd(bonsai.core.tool.Bsdd):
def is_applicable(cls, pset_uri: str, element: ifcopenshell.entity_instance) -> bool:
uris = set()
for reference in ifcopenshell.util.classification.get_references(element):
- if (uri := reference.Location) and uri.startswith(cls.identifier_url):
+ if (uri := reference.Location) and uri.startswith(cls.identifier_url()):
uris.add(uri)
class_uri, pset_name = pset_uri.rsplit("#", 1)
return class_uri in uris
diff --git a/src/bonsai/bonsai/tool/cad.py b/src/bonsai/bonsai/tool/cad.py
index 13678df15a..c91b5df0d8 100644
--- a/src/bonsai/bonsai/tool/cad.py
+++ b/src/bonsai/bonsai/tool/cad.py
@@ -841,7 +841,7 @@ class Cad:
return new_verts
@classmethod
- def region_2d_to_vector_3d_np(cls, region: bpy.types.Region, rv3d: bpy.types.RegionView3d, coord: Vector) -> Vector:
+ def region_2d_to_vector_3d_np(cls, region: bpy.types.Region, rv3d: bpy.types.RegionView3D, coord: Vector) -> Vector:
"""
Numpy version of view3d_utils.region_2d_to_vector_3d
Return a direction vector from the viewport at the specific 2d region
@@ -880,7 +880,7 @@ class Cad:
@classmethod
def region_2d_to_location_3d_np(
- cls, region: bpy.types.Region, rv3d: bpy.types.RegionView3d, coord: Vector, depth_location: Vector
+ cls, region: bpy.types.Region, rv3d: bpy.types.RegionView3D, coord: Vector, depth_location: Vector
) -> Vector:
"""
Numpy version of view3d_utils.region_2d_to_location_3d
@@ -913,7 +913,7 @@ class Cad:
@classmethod
def region_2d_to_origin_3d_np(
- cls, region: bpy.types.Region, rv3d: bpy.types.RegionView3d, coord: Vector, *, clamp: float = None
+ cls, region: bpy.types.Region, rv3d: bpy.types.RegionView3D, coord: Vector, *, clamp: float = None
) -> Vector:
"""
Numpy version of view3d_utils.region_2d_to_origin_3d
@@ -971,7 +971,7 @@ class Cad:
@classmethod
def location_3d_to_region_2d_np(
- cls, region: bpy.types.Region, rv3d: bpy.types.RegionView3d, coord: Vector, *, default=None
+ cls, region: bpy.types.Region, rv3d: bpy.types.RegionView3D, coord: Vector, *, default=None
) -> Vector:
"""
Numpy version of view3d_utils.location_3d_to_region_2d
diff --git a/src/bonsai/bonsai/tool/collector.py b/src/bonsai/bonsai/tool/collector.py
index b0aa358629..1e6653acd1 100644
--- a/src/bonsai/bonsai/tool/collector.py
+++ b/src/bonsai/bonsai/tool/collector.py
@@ -157,7 +157,8 @@ class Collector(bonsai.core.tool.Collector):
return
collection = bpy.data.collections.new(obj.name)
props.collection = collection
- collection.BIMCollectionProperties.obj = obj
+ collection_props = tool.Blender.get_collection_props(collection)
+ collection_props.obj = obj
return collection
@classmethod
diff --git a/src/bonsai/bonsai/tool/cost.py b/src/bonsai/bonsai/tool/cost.py
index 663b16040e..bbec525ee9 100644
--- a/src/bonsai/bonsai/tool/cost.py
+++ b/src/bonsai/bonsai/tool/cost.py
@@ -154,7 +154,7 @@ class Cost(bonsai.core.tool.Cost):
device = aud.Device()
# chaching.mp3 is by Lucish_ CC-BY-3.0 https://freesound.org/people/Lucish_/sounds/554841/
filepath = tool.Blender.get_data_dir_path("chaching.mp3").__str__()
- sound = aud.Sound(filepath)
+ sound = aud.Sound(filepath) # ty:ignore[too-many-positional-arguments]
device.play(sound)
@classmethod
diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py
index b8ec26fd37..86113b9c95 100644
--- a/src/bonsai/bonsai/tool/drawing.py
+++ b/src/bonsai/bonsai/tool/drawing.py
@@ -603,6 +603,10 @@ class Drawing(bonsai.core.tool.Drawing):
props = tool.Drawing.get_text_props(obj)
for literal_props in props.literals:
literal_data = bonsai.bim.helper.export_attributes(literal_props.attributes)
+ alignment = literal_props.align_vertical + "-" + literal_props.align_horizontal
+ if alignment == "middle-middle":
+ alignment = "center"
+ literal_data["BoxAlignment"] = alignment
literals.append(literal_data)
return literals
@@ -853,6 +857,8 @@ class Drawing(bonsai.core.tool.Drawing):
@classmethod
def edit_text_literals(cls, obj: bpy.types.Object, literal_attributes: dict) -> None:
+ if not literal_attributes:
+ return
assert (element := tool.Ifc.get_entity(obj))
assert (rep := cls.get_annotation_representation(element))
to_remove = [i for i in rep.Items if i.is_a("IfcTextLiteral")]
@@ -1176,22 +1182,26 @@ class Drawing(bonsai.core.tool.Drawing):
@classmethod
def import_text_attributes(cls, obj: bpy.types.Object) -> None:
- from bonsai.bim.module.drawing.prop import BOX_ALIGNMENT_POSITIONS
-
props = cls.get_text_props(obj)
props.literals.clear()
ifc_literals = cls.get_text_literal(obj, return_list=True)
assert isinstance(ifc_literals, list)
+
+ if ifc_literals:
+ first_alignment = getattr(ifc_literals[0], "BoxAlignment", None) or "bottom-left"
+ if first_alignment == "center":
+ first_alignment = "middle-middle"
+ props.align_vertical, props.align_horizontal = first_alignment.split("-")
+
for ifc_literal in ifc_literals:
literal_props = props.literals.add()
bonsai.bim.helper.import_attributes(ifc_literal, literal_props.attributes)
- box_alignment_mask = [False] * 9
- position_string = literal_props.attributes["BoxAlignment"].string_value
- box_alignment_mask[BOX_ALIGNMENT_POSITIONS.index(position_string)] = True
-
- literal_props.box_alignment = box_alignment_mask # pyright: ignore[reportAttributeAccessIssue]
+ alignment = getattr(ifc_literal, "BoxAlignment", None) or "bottom-left"
+ if alignment == "center":
+ alignment = "middle-middle"
+ literal_props.align_vertical, literal_props.align_horizontal = alignment.split("-")
literal_props.ifc_definition_id = ifc_literal.id()
from bonsai.bim.module.drawing.data import DecoratorData
@@ -1280,6 +1290,12 @@ class Drawing(bonsai.core.tool.Drawing):
def get_representation(cls, element, context):
return ifcopenshell.util.representation.get_representation(element, context)
+ @classmethod
+ def set_camera_name(cls, drawing: ifcopenshell.entity_instance, name: str) -> None:
+ camera = tool.Ifc.get_object(drawing)
+ if camera and camera.name != name:
+ camera.name = name
+
@classmethod
def set_drawing_collection_name(
cls, drawing: ifcopenshell.entity_instance, collection: bpy.types.Collection
diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py
index e24152d642..0d690d0308 100644
--- a/src/bonsai/bonsai/tool/geometry.py
+++ b/src/bonsai/bonsai/tool/geometry.py
@@ -1407,8 +1407,6 @@ class Geometry(bonsai.core.tool.Geometry):
:param representation_item: item to remove.
:param element: item's element. Is used to unmark manual booleans.
"""
- # NOTE: we assume it's not the last representation item
- # otherwise we probably would need to remove representation too
# NOTE: a lot of shared code with `geometry.remove_representation`
ifc_file = tool.Ifc.get()
shape_aspects: list[ifcopenshell.entity_instance] = []
@@ -1467,7 +1465,10 @@ class Geometry(bonsai.core.tool.Geometry):
cls.remove_representation_items_from_shape_aspect([representation_item], shape_aspect)
if representation:
- representation.Items = tuple(set(representation.Items) - {representation_item})
+ new_items = tuple(set(representation.Items) - {representation_item})
+ if not new_items:
+ return
+ representation.Items = new_items
also_consider = list(consider_inverses)
ifcopenshell.util.element.remove_deep2(ifc_file, representation_item, also_consider=also_consider)
diff --git a/src/bonsai/bonsai/tool/ifc.py b/src/bonsai/bonsai/tool/ifc.py
index 8cbd2a112b..6d79ce7582 100644
--- a/src/bonsai/bonsai/tool/ifc.py
+++ b/src/bonsai/bonsai/tool/ifc.py
@@ -197,12 +197,16 @@ class Ifc(bonsai.core.tool.Ifc):
if not cls.get():
return
+ # Clear all per-object msgbus subscriptions at once using the dedicated
+ # owner. After undo/redo, per-object Python wrappers have new
+ # identities so clearing by individual obj would miss stale
+ # subscriptions registered with the old wrappers.
+ bpy.msgbus.clear_by_owner(bonsai.bim.handler.object_subscription_owner)
+
for obj in bpy.data.objects:
if obj.library:
continue
- bpy.msgbus.clear_by_owner(obj)
-
element = cls.get_entity(obj)
if not element:
continue
@@ -217,8 +221,6 @@ class Ifc(bonsai.core.tool.Ifc):
if obj.library:
continue
- bpy.msgbus.clear_by_owner(obj)
-
style = cls.get_entity(obj)
if not style:
continue
diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py
index db557542bc..1843716f58 100644
--- a/src/bonsai/bonsai/tool/ifcgit.py
+++ b/src/bonsai/bonsai/tool/ifcgit.py
@@ -24,7 +24,7 @@ import re
import subprocess
import tempfile
from pathlib import Path
-from typing import TYPE_CHECKING, Any, Literal, Union
+from typing import TYPE_CHECKING, Any, Union
import bpy
@@ -128,39 +128,27 @@ class IfcGit:
cls.dos2unix(path_file)
repo.index.add(os.path.normpath(path_file))
repo.index.commit(message="Added " + os.path.relpath(path_file, repo.working_dir))
- bpy.ops.ifcgit.refresh()
@classmethod
def git_checkout(cls, path_file: str) -> None:
IfcGitRepo.repo.git.checkout(path_file)
@classmethod
- def checkout_new_branch(cls, path_file: str) -> None:
+ def checkout_new_branch(cls, path_file: str, branch_name: str) -> None:
"""Create a branch and move uncommitted changes to this branch"""
- props = cls.get_ifcgit_props()
- if props.new_branch_name:
- IfcGitRepo.repo.git.checkout(b=props.new_branch_name)
- props.display_branch = props.new_branch_name
- props.new_branch_name = ""
- bpy.ops.ifcgit.refresh()
+ IfcGitRepo.repo.git.checkout(b=branch_name)
@classmethod
- def git_commit(cls, path_file: str) -> None:
- props = cls.get_ifcgit_props()
+ def git_commit(cls, path_file: str, commit_message: str) -> None:
repo = IfcGitRepo.repo
if os.name == "nt":
cls.dos2unix(path_file)
repo.index.add(os.path.normpath(path_file))
- repo.index.commit(message=props.commit_message)
- props.commit_message = ""
+ repo.index.commit(message=commit_message)
@classmethod
- def add_tag(cls, repo: git.Repo) -> None:
- props = cls.get_ifcgit_props()
- item = props.ifcgit_commits[props.commit_index]
- repo.create_tag(props.new_tag_name, ref=item.hexsha, message=props.new_tag_message)
- props.new_tag_name = ""
- props.new_tag_message = ""
+ def add_tag(cls, repo: git.Repo, hexsha: str, tag_name: str, tag_message: str = "") -> None:
+ repo.create_tag(tag_name, ref=hexsha, message=tag_message)
@classmethod
def delete_tag(cls, repo: git.Repo, tag_name: git.TagReference) -> None:
@@ -168,20 +156,13 @@ class IfcGit:
repo.delete_tag(tag_name)
@classmethod
- def add_remote(cls, repo: git.Repo) -> None:
- props = cls.get_ifcgit_props()
- repo.create_remote(name=props.remote_name, url=props.remote_url)
- props.remote_name = ""
- props.remote_url = ""
+ def add_remote(cls, repo: git.Repo, remote_name: str, remote_url: str) -> None:
+ repo.create_remote(name=remote_name, url=remote_url)
@classmethod
- def delete_remote(cls, repo: git.Repo) -> None:
- props = cls.get_ifcgit_props()
- remote_name = props.select_remote
+ def delete_remote(cls, repo: git.Repo, remote_name: str) -> None:
if remote_name in repo.remotes:
repo.delete_remote(remote_name)
- if repo.remotes:
- props.select_remote = repo.remotes[0].name
@classmethod
def push(cls, repo: git.Repo, remote_name: str, branch_name: str) -> Union[str, None]:
@@ -193,16 +174,25 @@ class IfcGit:
return exc.stderr
@classmethod
- def create_new_branch(cls) -> None:
- """Convert a detached HEAD into a branch"""
- props = cls.get_ifcgit_props()
- repo = IfcGitRepo.repo
- new_branch = repo.create_head(props.new_branch_name)
- new_branch.checkout()
- props.display_branch = props.new_branch_name
- props.new_branch_name = ""
+ def is_head_detached(cls) -> bool:
+ return bool(IfcGitRepo.repo.head.is_detached)
- bpy.ops.ifcgit.refresh()
+ @classmethod
+ def repo_has_commits(cls) -> bool:
+ if IfcGitRepo.repo:
+ return bool(IfcGitRepo.repo.heads)
+ return False
+
+ @classmethod
+ def get_active_branch_name(cls) -> str:
+ return IfcGitRepo.repo.active_branch.name
+
+ @classmethod
+ def create_new_branch(cls, branch_name: str) -> None:
+ """Convert a detached HEAD into a branch"""
+ repo = IfcGitRepo.repo
+ new_branch = repo.create_head(branch_name)
+ new_branch.checkout()
@classmethod
def clear_commits_list(cls) -> None:
@@ -243,6 +233,7 @@ class IfcGit:
list_item.message = commit.message
list_item.author_name = commit.author.name
list_item.author_email = commit.author.email
+ list_item.committed_date = int(commit.committed_date)
if commit in commits_relevant:
list_item.relevant = True
if commit.hexsha in lookup:
@@ -284,7 +275,11 @@ class IfcGit:
if re.match("^Ifc", obj.name):
bpy.data.objects.remove(obj, do_unlink=True)
- bpy.data.orphans_purge(do_recursive=True)
+ bpy.data.orphans_purge(do_recursive=True) # ty:ignore[unknown-argument]
+
+ from bonsai.bim.module.root.data import IfcClassData
+
+ IfcClassData.is_loaded = False
settings = import_ifc.IfcImportSettings.factory(bpy.context, path_ifc, logging.getLogger("ImportIFC"))
settings.should_setup_viewport_camera = False
@@ -469,15 +464,6 @@ class IfcGit:
bpy.data.objects.remove(obj, do_unlink=True)
bpy.data.collections.remove(blender_collection)
- @classmethod
- def is_valid_branch_name(cls, new_branch_name: str):
- """Check if a branch name is valid and doesn't conflict with existing branches"""
- if not cls.is_valid_ref_format(new_branch_name):
- return False
- if new_branch_name in [branch.name for branch in IfcGitRepo.repo.branches]:
- return False
- return True
-
@classmethod
def config_ifcmerge(cls) -> None:
config_reader = IfcGitRepo.repo.config_reader()
@@ -519,49 +505,65 @@ class IfcGit:
output.write(line + b"\n")
@classmethod
- def execute_merge(cls, path_ifc: str, operator: bpy.types.Operator) -> Union[None, Literal[False]]:
+ def get_selected_branch(cls) -> Union[str, None]:
+ """Return the name of the branch at the selected commit matching display_branch, or None."""
props = cls.get_ifcgit_props()
repo = IfcGitRepo.repo
item = props.ifcgit_commits[props.commit_index]
lookup = cls.branches_by_hexsha(repo)
- if item.hexsha in lookup:
- for branch in lookup[item.hexsha]:
- if branch.name == props.display_branch:
- # this is a branch!
- if re.match("^(origin/)?(HEAD|main|master)$", branch.name):
- # preserve remote IDs in origin/main or main
- mergetool = "ifcmerge"
- else:
- # rewrite remote IDs
- mergetool = "ifcmerge-forward"
- try:
- # NOTE this is calling the git binary in a subprocess
- repo.git.merge(branch)
- except git.exc.GitCommandError:
- # merge is expected to fail, run ifcmerge
- try:
- repo.git.mergetool(tool=mergetool)
- except git.exc.GitCommandError as exc:
- message = re.sub("( stderr: '|')", "", exc.stderr)
- # ifcmerge failed, rollback
- repo.git.merge(abort=True)
+ if item.hexsha not in lookup:
+ return None
+ for branch in lookup[item.hexsha]:
+ if branch.name == props.display_branch:
+ return branch.name
+ return None
- operator.report({"ERROR"}, "IFC Merge failed:" + message)
- return False
- else:
- if os.name == "nt":
- cls.dos2unix(path_ifc)
- repo.index.add(os.path.normpath(path_ifc))
- repo.git.commit("--no-edit")
- except git.exc.GitError:
- operator.report({"ERROR"}, "Unknown IFC Merge failure")
- return False
+ @classmethod
+ def get_merge_tool(cls, branch_name: str) -> str:
+ if re.match("^(origin/)?(HEAD|main|master)$", branch_name):
+ return "ifcmerge"
+ return "ifcmerge-forward"
- props.display_branch = repo.active_branch.name
+ @classmethod
+ def git_merge(cls, branch_name: str) -> Union[str, None]:
+ """Attempt a git merge. Returns None on clean merge, 'conflict' on expected
+ GitCommandError, or 'error' on an unknown GitError."""
+ repo = IfcGitRepo.repo
+ branch = repo.branches[branch_name]
+ try:
+ repo.git.merge(branch)
+ return None
+ except git.exc.GitCommandError:
+ return "conflict"
+ except git.exc.GitError:
+ return "error"
- cls.load_project(path_ifc)
- cls.refresh_revision_list(path_ifc)
- cls.decolourise()
+ @classmethod
+ def git_mergetool(cls, mergetool: str) -> Union[str, None]:
+ """Run ifcmerge tool. Returns None on success, error message string on failure."""
+ repo = IfcGitRepo.repo
+ try:
+ repo.git.mergetool(tool=mergetool)
+ return None
+ except git.exc.GitCommandError as exc:
+ return re.sub("( stdout: '|')", "", exc.stdout)
+
+ @classmethod
+ def git_merge_abort(cls) -> None:
+ IfcGitRepo.repo.git.merge(abort=True)
+
+ @classmethod
+ def commit_merge(cls, path_ifc: str) -> None:
+ repo = IfcGitRepo.repo
+ if os.name == "nt":
+ cls.dos2unix(path_ifc)
+ repo.index.add(os.path.normpath(path_ifc))
+ repo.git.commit("--no-edit")
+
+ @classmethod
+ def set_display_branch(cls) -> None:
+ props = cls.get_ifcgit_props()
+ props.display_branch = IfcGitRepo.repo.active_branch.name
@classmethod
def entity_log(cls, path_ifc: str, step_id: int) -> str:
@@ -589,6 +591,18 @@ class IfcGit:
except FileNotFoundError:
operator.report({"ERROR"}, "Winget is not available. Make sure Windows Package Manager is installed.")
+ @classmethod
+ def select_first_remote(cls) -> None:
+ props = cls.get_ifcgit_props()
+ repo = IfcGitRepo.repo
+ if repo and repo.remotes:
+ props.select_remote = repo.remotes[0].name
+
+ @classmethod
+ def fetch(cls, remote_name: str) -> None:
+ repo = IfcGitRepo.repo
+ repo.remotes[remote_name].fetch()
+
@classmethod
def run_git_diff(cls, operator: bpy.types.Operator, save_to_temp: bool) -> None:
path = tool.Ifc.get_path()
diff --git a/src/bonsai/bonsai/tool/loader.py b/src/bonsai/bonsai/tool/loader.py
index 94b15e73b7..6d30192485 100644
--- a/src/bonsai/bonsai/tool/loader.py
+++ b/src/bonsai/bonsai/tool/loader.py
@@ -1070,11 +1070,6 @@ class Loader(bonsai.core.tool.Loader):
layer_set = material.ForLayerSet
offset = usage.OffsetFromReferenceLine * cls.unit_scale
sense_factor = 1 if usage.DirectionSense == "POSITIVE" else -1
- elif material.is_a("IfcMaterialLayerSet"):
- usage = None
- layer_set = material
- offset = 0
- sense_factor = 1
else:
return mesh
if len(layer_set.MaterialLayers) == 1:
@@ -1082,11 +1077,7 @@ class Loader(bonsai.core.tool.Loader):
bm = bmesh.new()
bm.from_mesh(mesh)
prev_co = None
- if not usage:
- sense_factor = 1 # Assume the extrusion vector points in the direction sense
- no = cls.get_extrusion_vector(element).normalized()
- co = Vector((0.0, 0.0, offset))
- elif usage.LayerSetDirection == "AXIS2":
+ if usage.LayerSetDirection == "AXIS2":
co = Vector((0.0, offset, 0.0))
no = cls.get_extrusion_vector(element).normalized()
no = no.cross(Vector([1.0, 0.0, 0.0]))
diff --git a/src/bonsai/bonsai/tool/material.py b/src/bonsai/bonsai/tool/material.py
index e09462a8c2..4c55123a9c 100644
--- a/src/bonsai/bonsai/tool/material.py
+++ b/src/bonsai/bonsai/tool/material.py
@@ -226,6 +226,10 @@ class Material(bonsai.core.tool.Material):
"IfcMaterialProfileSet",
]
+ @classmethod
+ def is_type_product(cls, element: ifcopenshell.entity_instance) -> bool:
+ return element.is_a("IfcTypeProduct")
+
@classmethod
def add_material_to_set(
cls, material_set: ifcopenshell.entity_instance, material: ifcopenshell.entity_instance
diff --git a/src/bonsai/bonsai/tool/misc.py b/src/bonsai/bonsai/tool/misc.py
index 890e8aac9b..5676e8f76c 100644
--- a/src/bonsai/bonsai/tool/misc.py
+++ b/src/bonsai/bonsai/tool/misc.py
@@ -16,7 +16,10 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see .
-from typing import Union
+from __future__ import annotations
+
+import ctypes
+from typing import TYPE_CHECKING, Any, NamedTuple, Union
import bmesh
import bpy
@@ -30,8 +33,164 @@ import bonsai.core.root
import bonsai.core.tool
import bonsai.tool as tool
+if TYPE_CHECKING:
+ from bonsai.bim.module.misc.prop import BIMMiscProperties
+
class Misc(bonsai.core.tool.Misc):
+
+ class BlenderCStructs:
+
+ class ListBase(ctypes.Structure):
+ _fields_ = [("first", ctypes.c_void_p), ("last", ctypes.c_void_p)]
+
+ class bUserMenu(ctypes.Structure):
+ pass
+
+ bUserMenu._fields_ = [
+ ("next", ctypes.c_void_p),
+ ("prev", ctypes.c_void_p),
+ ("space_type", ctypes.c_int8),
+ ("_pad0", ctypes.c_int8 * 7),
+ ("context", ctypes.c_char * 64),
+ ("items", ListBase),
+ ]
+
+ class bUserMenuItem(ctypes.Structure):
+ _fields_ = [
+ ("next", ctypes.c_void_p),
+ ("prev", ctypes.c_void_p),
+ ("ui_name", ctypes.c_char * 64),
+ ("type", ctypes.c_int8),
+ ("_pad0", ctypes.c_int8 * 7),
+ ]
+
+ class bUserMenuItem_Op(ctypes.Structure):
+ pass
+
+ bUserMenuItem_Op._fields_ = [
+ ("item", bUserMenuItem),
+ ("op_idname", ctypes.c_char * 64),
+ ("prop", ctypes.c_void_p),
+ ("op_prop_enum", ctypes.c_char * 64),
+ ("opcontext", ctypes.c_int8),
+ ("_pad0", ctypes.c_int8 * 7),
+ ]
+
+ class IDPropertyData(ctypes.Structure):
+ pass
+
+ IDPropertyData._fields_ = [
+ ("pointer", ctypes.c_void_p),
+ ("group", ListBase),
+ ("children_map", ctypes.c_void_p),
+ ("val", ctypes.c_int),
+ ("val2", ctypes.c_int),
+ ]
+
+ class IDProperty(ctypes.Structure):
+ pass
+
+ IDProperty._fields_ = [
+ ("next", ctypes.c_void_p),
+ ("prev", ctypes.c_void_p),
+ ("type", ctypes.c_int8),
+ ("subtype", ctypes.c_int8),
+ ("flag", ctypes.c_int16),
+ ("name", ctypes.c_char * 64),
+ ("_pad0", ctypes.c_int8 * 4),
+ ("data", IDPropertyData),
+ ("len", ctypes.c_int),
+ ("totallen", ctypes.c_int),
+ ("ui_data", ctypes.c_void_p),
+ ]
+
+ class QuickFavorites:
+ """Blender doesn't provide a good way to access or manage Quick Favorites
+ from the Python API. We use c-structs (ctypes) to read data directly from memory.
+ This is fragile and can break between Blender versions. We only use this for
+ reading data and never writing, to avoid the possibility of corrupting user preferences.
+ """
+
+ OFFSET_USER_MENUS: dict[tuple[int, int], int] = {
+ (4, 5): 10032,
+ (5, 0): 10032,
+ (5, 1): 10032,
+ }
+
+ @classmethod
+ def _read_idprop_value(cls, prop_ptr: int) -> Any:
+ IDP_STRING = 0
+ IDP_INT = 1
+ IDP_FLOAT = 2
+ IDP_BOOLEAN = 10
+
+ p = Misc.BlenderCStructs.IDProperty.from_address(prop_ptr)
+ if p.type == IDP_INT:
+ return p.data.val
+ elif p.type == IDP_BOOLEAN:
+ return bool(p.data.val)
+ elif p.type == IDP_FLOAT:
+ return ctypes.c_float.from_buffer_copy(ctypes.c_int(p.data.val)).value
+ elif p.type == IDP_STRING:
+ return ctypes.string_at(p.data.pointer).decode()
+ return f""
+
+ @classmethod
+ def _read_idprop_group(cls, group_ptr: int) -> dict[str, Any]:
+ root = Misc.BlenderCStructs.IDProperty.from_address(group_ptr)
+ result: dict[str, Any] = {}
+ child_ptr = root.data.group.first
+ while child_ptr:
+ child = Misc.BlenderCStructs.IDProperty.from_address(child_ptr)
+ result[child.name.decode()] = cls._read_idprop_value(child_ptr)
+ child_ptr = child.next
+ return result
+
+ class QuickFavoritesOperator(NamedTuple):
+ ui_name: str
+ op_idname_py: str
+ props: dict[str, Any]
+
+ @classmethod
+ def get_quick_favorites(cls) -> list[QuickFavoritesOperator]:
+ assert bpy.context.preferences
+ blender_version = bpy.app.version[:2]
+ offset = cls.OFFSET_USER_MENUS[blender_version]
+ prefs_address = bpy.context.preferences.as_pointer()
+ user_menus = Misc.BlenderCStructs.ListBase.from_address(prefs_address + offset)
+
+ result: list[cls.QuickFavoritesOperator] = []
+ SPACE_VIEW3D = 4
+
+ node = user_menus.first
+ while node:
+ user_menu = Misc.BlenderCStructs.bUserMenu.from_address(node)
+ if user_menu.space_type != SPACE_VIEW3D:
+ node = user_menu.next
+ continue
+ item_ptr = user_menu.items.first
+ while item_ptr:
+ umi = Misc.BlenderCStructs.bUserMenuItem.from_address(item_ptr)
+ if umi.type == 2: # OPERATOR
+ op = Misc.BlenderCStructs.bUserMenuItem_Op.from_address(item_ptr)
+ props = cls._read_idprop_group(op.prop) if op.prop else {}
+ result.append(
+ cls.QuickFavoritesOperator(
+ ui_name=op.item.ui_name.decode(),
+ op_idname_py=tool.Blender.operator_idname_to_py(op.op_idname.decode()),
+ props=props,
+ )
+ )
+ item_ptr = umi.next
+ node = user_menu.next
+
+ return result
+
+ @classmethod
+ def get_misc_props(cls) -> BIMMiscProperties:
+ return bpy.context.scene.BIMMiscProperties
+
@classmethod
def get_object_storey(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]:
element = tool.Ifc.get_entity(obj)
diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py
index 23c0071b1b..60f79cc26e 100644
--- a/src/bonsai/bonsai/tool/model.py
+++ b/src/bonsai/bonsai/tool/model.py
@@ -76,6 +76,7 @@ if TYPE_CHECKING:
BIMRailingProperties,
BIMRoofProperties,
BIMStairProperties,
+ BIMSverchokProperties,
BIMWindowProperties,
)
@@ -87,23 +88,27 @@ class Model(bonsai.core.tool.Model):
@classmethod
def get_door_props(cls, obj: bpy.types.Object) -> BIMDoorProperties:
- return obj.BIMDoorProperties
+ return obj.BIMDoorProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_window_props(cls, obj: bpy.types.Object) -> BIMWindowProperties:
- return obj.BIMWindowProperties
+ return obj.BIMWindowProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_stair_props(cls, obj: bpy.types.Object) -> BIMStairProperties:
- return obj.BIMStairProperties
+ return obj.BIMStairProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_roof_props(cls, obj: bpy.types.Object) -> BIMRoofProperties:
- return obj.BIMRoofProperties
+ return obj.BIMRoofProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_railing_props(cls, obj: bpy.types.Object) -> BIMRailingProperties:
- return obj.BIMRailingProperties
+ return obj.BIMRailingProperties # pyright: ignore[reportAttributeAccessIssue]
+
+ @classmethod
+ def get_sverchok_props(cls, obj: bpy.types.Object) -> BIMSverchokProperties:
+ return obj.BIMSverchokProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_array_props(cls, obj: bpy.types.Object) -> BIMArrayProperties:
diff --git a/src/bonsai/bonsai/tool/nest.py b/src/bonsai/bonsai/tool/nest.py
index a386e402ec..5a1b2c83b2 100644
--- a/src/bonsai/bonsai/tool/nest.py
+++ b/src/bonsai/bonsai/tool/nest.py
@@ -45,9 +45,23 @@ class Nest(bonsai.core.tool.Nest):
related_object = tool.Ifc.get_entity(related_obj)
if not relating_object or not related_object:
return False
- if relating_object.is_a("IfcElement") and related_object.is_a("IfcElement"):
- return True
- return False
+ if relating_object == related_object:
+ return False
+ 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
+ # proposed parent and reject if we encounter the proposed child.
+ ancestor = ifcopenshell.util.element.get_parent(relating_object)
+ seen = {relating_object}
+ while ancestor:
+ if ancestor == related_object:
+ return False
+ if ancestor in seen:
+ break
+ seen.add(ancestor)
+ ancestor = ifcopenshell.util.element.get_parent(ancestor)
+ return True
@classmethod
def disable_editing(cls, obj: bpy.types.Object) -> None:
@@ -123,11 +137,11 @@ class Nest(bonsai.core.tool.Nest):
return {"FINISHED"}
@classmethod
- def disable_nest_mode(cls):
- context = bpy.context
- props = context.scene.BIMNestProperties
+ def disable_nest_mode(cls) -> None:
+ props = cls.get_nest_props()
for obj_prop in props.not_editing_objects:
obj = obj_prop.obj
+ assert obj and obj.original
obj.original.display_type = obj_prop.previous_display_type
element = tool.Ifc.get_entity(obj)
if not element:
@@ -135,7 +149,7 @@ class Nest(bonsai.core.tool.Nest):
components = ifcopenshell.util.element.get_components(tool.Ifc.get_entity(props.editing_nest))
objs = [tool.Ifc.get_object(component) for component in components]
- if context.space_data.local_view:
+ if bpy.context.space_data.local_view:
bpy.ops.view3d.localview()
props.in_nest_mode = False
diff --git a/src/bonsai/bonsai/tool/polyline.py b/src/bonsai/bonsai/tool/polyline.py
index 5a91fc79b8..542bc23005 100644
--- a/src/bonsai/bonsai/tool/polyline.py
+++ b/src/bonsai/bonsai/tool/polyline.py
@@ -533,9 +533,12 @@ class Polyline(bonsai.core.tool.Polyline):
polyline_data = polyline_data[0]
polyline_points = polyline_data.polyline_points
if polyline_points:
- # Avoids creating two points at the same location
- for point in polyline_points[1:]: # The first can be repeated to form a wall loop
+ # Avoids creating two points at the same location.
+ # The only exception is repeating the first point to close a loop (requires >= 3 existing points).
+ for i, point in enumerate(polyline_points):
if (x, y, z) == (point.x, point.y, point.z):
+ if i == 0 and len(polyline_points) >= 3:
+ continue
return "Cannot create two points at the same location"
# Avoids creating overlapping edges
if len(polyline_points) > 1:
diff --git a/src/bonsai/bonsai/tool/project.py b/src/bonsai/bonsai/tool/project.py
index d92bf19cf5..1bfb239726 100644
--- a/src/bonsai/bonsai/tool/project.py
+++ b/src/bonsai/bonsai/tool/project.py
@@ -24,7 +24,15 @@ import shutil
from collections import defaultdict
from math import radians
from pathlib import Path
-from typing import TYPE_CHECKING, Any, NamedTuple, Optional
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ Literal,
+ NamedTuple,
+ NotRequired,
+ Optional,
+ TypedDict,
+)
import bpy
import ifcopenshell
@@ -35,7 +43,7 @@ import ifcopenshell.util.shape_builder
import numpy as np
import numpy.typing as npt
from ifcopenshell.api.project.append_asset import APPENDABLE_ASSET_TYPES
-from mathutils import Matrix
+from mathutils import Matrix, Vector
import bonsai.bim.schema
import bonsai.core.aggregate
@@ -600,6 +608,49 @@ class Project(bonsai.core.tool.Project):
class Link:
"""Tools for working with linked models."""
+ class LinkedObjectChunk(TypedDict):
+ """There's actually no dictionary with those keys,
+ just using this class to document what keys we do assign to the objects
+ that represent chunks of the linked models.
+ """
+
+ guids: list[str]
+ """List of guids present in the object."""
+
+ guid_ids: list[int]
+ """Number of faces that belong to each guid.
+
+ E.g. if chunk consists of two 12 tris cubes:
+ ```
+ guids = ["aaa", "bbb"]
+ # Meaning object has 24 polygons
+ # [0;11] is part of "aaa", [12:23] is part of "bbb".
+ guid_ids = [12, 24]
+ ```
+ """
+
+ db: str
+ """Absolute filepath to .ifc.cache.sqlite."""
+
+ ifc_filepath: str
+ """Absolute filepath to .ifc."""
+
+ # Only added when object is queried.
+ selected_vertices: NotRequired[list[tuple[int, int, int]]]
+ selected_edges: NotRequired[list[tuple[int, int]]]
+ selected_tris: NotRequired[list[tuple[int, int, int]]]
+
+ hidden_indices: NotRequired[list[int]]
+ """List of hidden indices in "guids".
+ Note that entire object also can be hidden by ``hide_viewport`` and then "hidden_indices" won't be set.
+
+ ```
+ guids = ["aaa", "bbb", "ccc"]
+ # guid "bbb" is hidden.
+ hidden_indices = [1]
+ ```
+ """
+
@classmethod
def is_linked_element(cls, obj: bpy.types.Object) -> bool:
return "guids" in obj
@@ -670,11 +721,17 @@ class Project(bonsai.core.tool.Project):
return slice(guid_start_index, guid_end_index)
@classmethod
- def hide_linked_element(cls, obj: bpy.types.Object, guid: str) -> None:
- verts = tool.Project.Link.get_linked_element_verts(obj, guid)
-
+ def setup_hide_modifier(
+ cls,
+ obj: bpy.types.Object,
+ hide_type: Literal["hide_selected", "hide_unselected"],
+ ) -> bpy.types.VertexGroup:
# `MeshPolygon.hide` works only in EDIT mode,
# so we use vertex groups + Mask modifier.
+ # But since for hiding we're modifying object in a linked model,
+ # then those changes are ephemeral and not fully supported by Blender
+ # e.g. they're not tracked by UNDO system.
+
MODIFIER_VG_NAME = "BBIM_HIDE_LINKED_GEOMETRY"
vertex_groups = obj.vertex_groups
@@ -688,8 +745,15 @@ class Project(bonsai.core.tool.Project):
modifier = modifiers.new(MODIFIER_VG_NAME, "MASK")
assert isinstance(modifier, bpy.types.MaskModifier)
modifier.vertex_group = MODIFIER_VG_NAME
- modifier.invert_vertex_group = True
+ # Mask modifier by default shows only geometry from the provided vertex group.
+ modifier.invert_vertex_group = hide_type == "hide_selected"
+ return vertex_group
+ @classmethod
+ def hide_linked_element(cls, obj: bpy.types.Object, guid: str) -> None:
+ verts = tool.Project.Link.get_linked_element_verts(obj, guid)
+
+ vertex_group = cls.setup_hide_modifier(obj, "hide_selected")
vertex_group.add(verts, 1.0, "REPLACE")
hidden_indices: list[int] = list(obj.get("hidden_indices") or [])
@@ -706,12 +770,45 @@ class Project(bonsai.core.tool.Project):
assert col
for obj_ in col.objects:
+ obj_.hide_viewport = False
+
if "hidden_indices" not in obj_:
continue
obj_.vertex_groups.clear()
obj_.modifiers.clear()
del obj_["hidden_indices"]
+ @classmethod
+ def hide_all_elements_except(cls, link: Link, queried_obj: bpy.types.Object, queried_guid: str) -> None:
+ handle = tool.Project.get_link_empty_handle(link)
+ assert handle
+ col = handle.instance_collection
+ assert col
+
+ for obj_ in col.objects:
+ if "guids" not in obj_:
+ continue
+ if obj_ != queried_obj:
+ # Just hide the entire chunk, if queried guid is not part of it.
+ obj_.hide_viewport = True
+ continue
+
+ guids: list[str] = obj_["guids"]
+ queried_guid_index = guids.index(queried_guid)
+
+ # Get vertices for the queried element before modifying hidden_indices.
+ queried_verts = cls.get_linked_element_verts(obj_, queried_guid)
+ vertex_group = cls.setup_hide_modifier(obj_, "hide_unselected")
+
+ assert isinstance(mesh := obj_.data, bpy.types.Mesh)
+ # Clean up possible previously hidden elements.
+ vertex_group.remove(range(len(mesh.vertices)))
+
+ vertex_group.add(queried_verts, 1.0, "REPLACE")
+
+ # Mark all guids as hidden except the queried one.
+ obj_["hidden_indices"] = [i for i in range(len(guids)) if i != queried_guid_index]
+
@classmethod
def select_linked_element_geom(cls, obj: bpy.types.Object, guid: str) -> None:
slice_ = cls.get_linked_element_geom_slice(obj, guid)
diff --git a/src/bonsai/bonsai/tool/raycast.py b/src/bonsai/bonsai/tool/raycast.py
index d98aaf26ab..c16bc55564 100644
--- a/src/bonsai/bonsai/tool/raycast.py
+++ b/src/bonsai/bonsai/tool/raycast.py
@@ -16,6 +16,9 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see .
+from __future__ import annotations
+
+import math
from typing import Union
import bmesh
@@ -24,9 +27,11 @@ import mathutils
import numpy as np
from mathutils import Vector
+from bpy_extras import view3d_utils
+
import bonsai.core.tool
import bonsai.tool as tool
-
+from bpy_extras import view3d_utils
class Raycast(bonsai.core.tool.Raycast):
offset = 10
@@ -41,6 +46,7 @@ class Raycast(bonsai.core.tool.Raycast):
(0, -offset),
(offset, -offset),
)
+ snap_objs = []
@classmethod
def get_visible_objects(cls, context: bpy.types.Context):
@@ -69,8 +75,10 @@ class Raycast(bonsai.core.tool.Raycast):
rv3d = context.region_data
assert rv3d
view_location = rv3d.view_matrix.inverted().translation
+ view_normal = rv3d.view_rotation @ mathutils.Vector((0.0, 0.0, -1.0))
obj_matrix = obj.matrix_world.copy()
bbox = [obj_matrix @ Vector(v) for v in obj.bound_box]
+ bbox_edges = [(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)]
transposed_bbox: list[Vector] = []
bbox_2d: list[float] = []
@@ -94,8 +102,25 @@ class Raycast(bonsai.core.tool.Raycast):
for v in bbox:
coord_2d = tool.Cad.location_3d_to_region_2d_np(context.region, context.space_data.region_3d, v)
- if coord_2d is not None:
- transposed_bbox.append(coord_2d)
+ transposed_bbox.append(coord_2d)
+
+ if not any(transposed_bbox):
+ transposed_bbox = []
+ # If there are None values in transposed_bbox it means that there are vertices behind the camera
+ # so we get the intersection of the edge with the region border
+ # new_bbox = []
+ if any(transposed_bbox) and not all(transposed_bbox):
+ new_bbox = transposed_bbox.copy()
+ new_bbox = [x for x in new_bbox if x is not None]
+ for edge in bbox_edges:
+ if (transposed_bbox[edge[0]] is None) ^ (transposed_bbox[edge[1]] is None):
+ point, _ = cls.intersect_edge_region_border(
+ context.region, context.space_data, rv3d, bbox[edge[0]], bbox[edge[1]]
+ )
+ if point:
+ new_bbox.append(point)
+ if new_bbox:
+ transposed_bbox = new_bbox
region = context.region
borders = (0, region.width, 0, region.height)
@@ -117,6 +142,96 @@ class Raycast(bonsai.core.tool.Raycast):
return (obj, bbox_2d)
return None
+ def intersect_edge_region_border(region, space, rv3d, v1, v2):
+ def segment_intersect_near_plane(view_matrix, clip_start, p_world_a, p_world_b):
+ a_view = view_matrix @ p_world_a
+ b_view = view_matrix @ p_world_b
+ z_near = -clip_start
+ za = a_view.z
+ zb = b_view.z
+ denom = zb - za
+ if denom == 0.0:
+ return None, None
+ t = (z_near - za) / denom
+ if t < 0.0 or t > 1.0:
+ return None, None
+ p_view = a_view.lerp(b_view, t)
+ cam_world = view_matrix.inverted()
+ p_world = cam_world @ p_view
+ return p_world, t
+
+ def is_inside_region(pt2d, region):
+ return 0.0 <= pt2d.x <= region.width and 0.0 <= pt2d.y <= region.height
+
+ def clamp_to_region_border(point2d, region):
+ x, y = point2d
+ x_clamped = max(0.0, min(region.width, x))
+ y_clamped = max(0.0, min(region.height, y))
+ return Vector((x_clamped, y_clamped))
+
+ def find_nearby_onscreen_point(region, rv3d, p1, p2, initial_t_on_segment, max_iters=40, step=0.05):
+ """
+ Use iterative approach: move t toward 0. Returns the first point that is inside region border
+ """
+ t = initial_t_on_segment
+ for i in range(max_iters):
+ test_3d = p1.lerp(p2, t)
+ test_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, test_3d)
+ if test_2d is not None and is_inside_region(test_2d, region):
+ return test_3d, test_2d, t
+ # move t toward 0 by reducing it by a fraction of its current value
+ t -= step
+ # if t is already very small, break
+ if t <= 1e-6:
+ break
+
+ return None, None, None
+
+ # Ensures that all the calculation uses the same direction based on which point is on the screen
+ if view3d_utils.location_3d_to_region_2d(region, rv3d, v1):
+ onscreen_vert = v1
+ offscreen_vert = v2
+ else:
+ onscreen_vert = v2
+ offscreen_vert = v1
+ # v2, v1 = v1, v2
+
+ clip_start = space.clip_start
+ view_mat = rv3d.view_matrix
+ inter_world, t_on_ab = segment_intersect_near_plane(view_mat, clip_start, onscreen_vert, offscreen_vert)
+
+ if inter_world is None:
+ print("No intersection with viewport near plane found for the segment.")
+ return None, None
+
+ init_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, inter_world)
+
+ if init_2d is not None and is_inside_region(init_2d, region):
+ final_world = inter_world
+ final_2d = init_2d
+ final_t = t_on_ab
+ else:
+ found_world, found_2d, found_t = find_nearby_onscreen_point(
+ region, rv3d, onscreen_vert, offscreen_vert, t_on_ab, max_iters=600, step=0.01
+ )
+ if found_world is None:
+ if init_2d is None:
+ print("Initial projection invalid and iterative search failed.")
+ return None, None
+ # fallback: clamp projected point to border via manual mapping
+ final_2d = clamp_to_region_border(init_2d, region)
+ final_world = None
+ final_t = None
+ # print("Iterative search failed; using clamped 2D:", final_2d)
+ else:
+ final_world = found_world
+ final_2d = found_2d
+ final_t = found_t
+ # print(f"Found onscreen point at t={final_t:.4f}")
+
+ # print("Final 2D:", final_2d)
+ return final_2d, v2
+
@classmethod
def intersect_mouse_2d_bounding_box(cls, mouse_pos: tuple[int, int], bbox: list[float, float, float, float]):
x, y = mouse_pos
@@ -232,6 +347,161 @@ class Raycast(bonsai.core.tool.Raycast):
else:
return None, None, None
+ @classmethod
+ def ray_cast_by_proximity_2d(
+ cls,
+ context: bpy.types.Context,
+ event: bpy.types.Event,
+ snap_obj: SnapObj,
+ ):
+
+ def divide_vector(start, end, n):
+ points = []
+ delta = (end - start) / n
+ for i in range(1, n):
+ point = start + i * delta
+ points.append(point)
+ return points
+
+ region = context.region
+ rv3d = context.region_data
+ mouse_pos = event.mouse_region_x, event.mouse_region_y
+ ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
+ points = []
+
+ try:
+ loc = tool.Cad.region_2d_to_location_3d_np(region, rv3d, mouse_pos, ray_direction)
+ except:
+ loc = Vector((0, 0, 0))
+
+ verts_2d = [
+ view3d_utils.location_3d_to_region_2d(region, rv3d, v) for v in snap_obj.verts_3d
+ ] # Numpy version is worst in performance
+
+ intersected = snap_obj.raycast_boxes(
+ context, event, snap_obj.root, intersected=[], rays=(ray_origin, ray_direction)
+ )
+ edges = []
+ for it in intersected:
+ edges.extend(it.edges)
+ edges = set(edges)
+
+ edge_verts = {}
+ for e in edges:
+ verts_idx = tuple(snap_obj.obj.data.edges[e].vertices)
+ verts = snap_obj.obj.data.vertices
+ v1 = snap_obj.obj.matrix_world @ verts[verts_idx[0]].co
+ v1_2d = verts_2d[verts_idx[0]]
+ v2 = snap_obj.obj.matrix_world @ verts[verts_idx[1]].co
+ v2_2d = verts_2d[verts_idx[1]]
+ if (v1_2d is None) ^ (v2_2d is None):
+ point, _ = cls.intersect_edge_region_border(region, context.space_data, rv3d, v1, v2)
+ if v1_2d is None:
+ edge_verts[e] = (point, v2_2d)
+ else:
+ edge_verts[e] = (v1_2d, point)
+ else:
+ edge_verts[e] = (v1_2d, v2_2d)
+
+ snap_threshold = 10.0
+
+ for i, point in enumerate(verts_2d):
+ if not point:
+ continue
+ distance = (Vector(mouse_pos) - point).length
+ if distance <= snap_threshold:
+ snap_point = {
+ "object": snap_obj.obj,
+ "type": "Vertex",
+ "point": snap_obj.verts_3d[i],
+ "distance": distance / 10,
+ }
+ points.append(snap_point)
+
+ count = 0
+ selected_edges = {}
+ for e in edges:
+ p0, p1 = edge_verts[e]
+ p0x, p0y = p0
+ p1x, p1y = p1
+ px, py = mouse_pos
+
+ # segment vector = p1 - p0
+ sx = p1x - p0x
+ sy = p1y - p0y
+
+ # seg length squared
+ seg_len_sq = sx * sx + sy * sy
+
+ if seg_len_sq == 0.0:
+ # degenerate segment: return distance to p0
+ dx = px - p0x
+ dy = py - p0y
+ dist = math.hypot(dx, dy)
+ return dist, (p0x, p0y), 0.0
+
+ # project (p - p0) onto seg: t = dot(p-p0, seg) / |seg|^2
+ apx = px - p0x
+ apy = py - p0y
+ t = (apx * sx + apy * sy) / seg_len_sq
+
+ # clamp to segment
+ if t <= 0.0:
+ t_clamped = 0.0
+ cx, cy = p0x, p0y
+ elif t >= 1.0:
+ t_clamped = 1.0
+ cx, cy = p1x, p1y
+ else:
+ t_clamped = t
+ cx = p0x + sx * t_clamped
+ cy = p0y + sy * t_clamped
+
+ dx = px - cx
+ dy = py - cy
+ dist = math.hypot(dx, dy)
+ if dist <= snap_threshold:
+ selected_edges[dist] = e
+
+ if selected_edges:
+ min_dist = float("inf")
+ for key in selected_edges:
+ if key < min_dist:
+ min_dist = key
+
+ idx = snap_obj.obj.data.edges[selected_edges[min_dist]].vertices
+ edge_verts = (snap_obj.verts_3d[idx[0]], snap_obj.verts_3d[idx[1]])
+ division_points = divide_vector(
+ edge_verts[0], edge_verts[1], 2
+ ) # TODO Make it work for different divisions
+ for division_point in division_points:
+ intersection = tool.Cad.point_on_edge(division_point, (ray_target, loc))
+ distance = (division_point - intersection).length
+ if distance < snap_threshold:
+ snap_point = {
+ "object": snap_obj.obj,
+ "type": "Edge Center",
+ "point": division_point.copy(),
+ "distance": distance,
+ }
+ points.append(snap_point)
+
+ intersection = tool.Cad.intersect_edges_v2((ray_target, loc), edge_verts)
+ if intersection[0]:
+ if tool.Cad.is_point_on_edge(intersection[1], edge_verts):
+ distance = (intersection[1] - intersection[0]).length
+ if distance < snap_threshold:
+ snap_point = {
+ "object": snap_obj.obj,
+ "type": "Edge",
+ "point": intersection[1].copy(),
+ "edge_verts": edge_verts,
+ "distance": distance,
+ }
+ points.append(snap_point)
+
+ return points
+
@classmethod
def ray_cast_by_proximity(
cls,
@@ -457,7 +727,9 @@ class Raycast(bonsai.core.tool.Raycast):
if bbox_2d:
if tool.Raycast.intersect_mouse_2d_bounding_box(mouse_pos, bbox_2d):
if tool.Raycast.object_is_visible_in_clipping_plane(obj):
- objs_to_raycast.append(obj)
+ snap_obj = cls.create_snap_obj(obj)
+ if snap_obj is not None:
+ objs_to_raycast.append(snap_obj)
return objs_to_raycast
@@ -474,12 +746,6 @@ class Raycast(bonsai.core.tool.Raycast):
face_index = None
# Wireframes
if obj.type in {"EMPTY", "CURVE"} or (hasattr(obj.data, "polygons") and len(obj.data.polygons) == 0):
- snap_points = tool.Raycast.ray_cast_by_proximity(context, event, obj)
- if snap_points:
- hit = sorted(snap_points, key=lambda x: x["distance"])[0]["point"]
- if hit:
- hit_world = obj.original.matrix_world @ hit
- return obj, hit_world, face_index
return None, None, None
# Meshes
else:
@@ -514,19 +780,20 @@ class Raycast(bonsai.core.tool.Raycast):
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
- for obj in objs_to_raycast:
+ for snap_obj in objs_to_raycast:
if not include_wireframes and (
- obj.type in {"EMPTY", "CURVE"} or (hasattr(obj.data, "polygons") and len(obj.data.polygons) == 0)
+ snap_obj.obj.type in {"EMPTY", "CURVE"}
+ or (hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0)
):
continue
- snap_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, obj)
+ hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
if hit is not None:
length_squared = (hit - ray_origin).length_squared
if best_obj is None or length_squared < best_length_squared:
best_length_squared = length_squared
- best_obj = snap_obj
+ best_obj = hit_obj
best_hit = hit
best_face_index = face_index
@@ -536,6 +803,79 @@ class Raycast(bonsai.core.tool.Raycast):
else:
return None, None, None
+ @classmethod
+ def ray_cast_and_get_closest_to_camera_snaps(
+ cls,
+ context: bpy.types.Context,
+ event: bpy.types.Event,
+ objs_to_raycast: list[bpy.types.Object],
+ ) -> Union[tuple[bpy.types.Object, Vector, int], tuple[None, None, None]]:
+ closest_length_squared = 1.0
+ closest_obj = None
+ closest_hit = None
+ closest_face_index = None
+
+ ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
+
+ closest_snaps = []
+ hit = None
+
+ for snap_obj in objs_to_raycast:
+ if snap_obj.obj.type in {"EMPTY", "CURVE"} or (
+ hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0
+ ):
+ # For wireframe objects we have to test all the snaps to see which is closer
+ snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
+ closest_wf_hit = None
+ closest_wf_length_squared = 1.0
+ closest_wf_point = None
+ if snap_points:
+ for point in snap_points:
+ point["group"] = "Wireframe"
+ closest_snaps.append(point)
+ length = (point["point"] - ray_origin).length_squared
+ if closest_wf_hit is None or length < closest_wf_length_squared:
+ closest_wf_length_squared = length
+ closest_wf_hit = point["point"]
+ closest_wf_point = point
+
+ if closest_wf_point:
+ hit_obj = closest_wf_point["object"]
+ hit = closest_wf_point["point"]
+ face_index = None
+
+ else:
+ # Solid objects
+ hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
+
+ if hit:
+ snap_point = {
+ "point": hit,
+ "type": "Face",
+ "group": "Object",
+ "object": hit_obj,
+ "face_index": face_index,
+ "distance": 9, # High value so it has low priority
+ }
+ closest_snaps.append(snap_point)
+
+ # Here we test which is closer, including wireframe and solid objects
+ if hit is not None:
+ length_squared = (hit - ray_origin).length_squared
+ if closest_obj is None or length_squared < closest_length_squared:
+ closest_length_squared = length_squared
+ closest_obj = hit_obj
+ closest_hit = hit
+ closest_face_index = face_index
+
+ # Label snaps from the closest object
+ if closest_obj is not None:
+ for snap in closest_snaps:
+ if snap["object"] == closest_obj:
+ snap["is_closest_to_camera"] = True
+
+ return closest_snaps
+
@classmethod
def calculate_snap_threshold(cls, view_distance):
snap_threshold = view_distance / 100
@@ -547,3 +887,262 @@ class Raycast(bonsai.core.tool.Raycast):
if lens < 50:
snap_threshold *= value
return snap_threshold
+
+ @classmethod
+ def create_snap_obj(cls, obj):
+ if obj.data is None or not isinstance(obj.data, bpy.types.Mesh):
+ return None
+ for snap_obj in cls.snap_objs:
+ if obj.name == snap_obj.obj.name:
+ return snap_obj
+ snap_obj = SnapObj(obj)
+ cls.snap_objs.append(snap_obj)
+ return snap_obj
+
+ @classmethod
+ def clear_snap_objs(cls):
+ TreeNode.__clear_all__()
+ SnapObj.__clear_all__()
+ cls.snap_objs.clear()
+
+
+class TreeNode:
+ all = []
+
+ def __init__(self, box: tuple):
+ self.__class__.all.append(self)
+ self.box = box
+ self.child_a = None
+ self.child_b = None
+ self.edges = []
+
+ def __clear_all__():
+ for instance in TreeNode.all:
+ del instance
+ TreeNode.all.clear()
+
+
+class SnapObj:
+ max_depth = 9
+ all = []
+
+ def __init__(self, obj: bpy.types.Object):
+ self.__class__.all.append(self)
+ self.obj = obj
+ self.root = self._create_root_node()
+ self.root.edges = [e.index for e in obj.data.edges]
+ self.split_box(self.root, 0)
+ self.verts_3d = [obj.matrix_world @ v.co for v in obj.data.vertices]
+ self.snap_points = []
+
+ def __clear_all__():
+ for instance in SnapObj.all:
+ del instance
+ SnapObj.all.clear()
+
+ def _create_root_node(self) -> TreeNode:
+ bbox = tool.Blender.get_object_bounding_box(self.obj)
+ min_point = self.obj.matrix_world @ bbox["min_point"]
+ max_point = self.obj.matrix_world @ bbox["max_point"]
+ new_bbox = self.expand_bounding_box((min_point, max_point))
+ return TreeNode(new_bbox)
+
+ def divide_bounding_box_along_longest_axis(
+ self, min_pt: Vector, max_pt: Vector
+ ) -> Union[tuple[Vector, Vector], tuple[Vector, Vector]]:
+ """
+ Divide a bounding box into two equal parts along the axis with the longest dimension.
+
+ Args:
+ min_pt: The minimum point of the bounding box.
+ max_pt: The maximum point of the bounding box.
+
+ Returns:
+ list: A list of two tuples, each containing the minimum and maximum points of the divided boxes.
+ """
+
+ # Calculate the dimensions of the box
+ dx = max_pt.x - min_pt.x
+ dy = max_pt.y - min_pt.y
+ dz = max_pt.z - min_pt.z
+
+ # Determine the axis with the longest dimension
+ if dx >= dy and dx >= dz:
+ # Divide along the x-axis
+ mid_x = min_pt.x + dx / 2
+ box1 = (min_pt, Vector((mid_x, max_pt.y, max_pt.z)))
+ box2 = (Vector((mid_x, min_pt.y, min_pt.z)), max_pt)
+ elif dy >= dx and dy >= dz:
+ # Divide along the y-axis
+ mid_y = min_pt.y + dy / 2
+ box1 = (min_pt, Vector((max_pt.x, mid_y, max_pt.z)))
+ box2 = (Vector((min_pt.x, mid_y, min_pt.z)), max_pt)
+ else:
+ # Divide along the z-axis
+ mid_z = min_pt.z + dz / 2
+ box1 = (min_pt, Vector((max_pt.x, max_pt.y, mid_z)))
+ box2 = (Vector((min_pt.x, min_pt.y, mid_z)), max_pt)
+
+ return [box1, box2]
+
+ def expand_bounding_box(self, box: tuple[Vector, Vector], offset: float = 0.1) -> tuple[Vector, Vector]:
+ """
+ Expand a 3D bounding box by a given offset.
+
+ Args:
+ min_pt: The minimum point of the bounding box.
+ max_pt: The maximum point of the bounding box.
+ offset: The offset to expand the bounding box by.
+
+ Returns:
+ tuple: A tuple containing the new minimum and maximum points of the expanded bounding box.
+ """
+
+ min_pt, max_pt = box
+ # Calculate the new minimum and maximum points
+ new_min_pt = Vector((min_pt.x - offset, min_pt.y - offset, min_pt.z - offset))
+ new_max_pt = Vector((max_pt.x + offset, max_pt.y + offset, max_pt.z + offset))
+
+ return new_min_pt, new_max_pt
+
+ def split_box(self, parent: TreeNode, depth: int):
+ """
+ Splits the bounding box creating two child nodes to compose a BVH Tree recursively.
+
+ Args:
+ parent: the TreeNode instance that represents the parent node of a BVH Tree.
+ depth: the depth of the BVH Tree no be used in recursion.
+ """
+ if depth > self.max_depth:
+ return
+ box_a, box_b = self.divide_bounding_box_along_longest_axis(parent.box[0], parent.box[1])
+ parent.child_a = TreeNode(box_a)
+ parent.child_b = TreeNode(box_b)
+ edges_a = []
+ edges_b = []
+ for e in parent.edges:
+ verts_idx = [v for v in self.obj.data.edges[e].vertices]
+ verts_coords = []
+ for idx in verts_idx:
+ if idx < len(self.obj.data.vertices):
+ verts_coords.append(self.obj.matrix_world @ self.obj.data.vertices[idx].co)
+ if self.line_intersects_box(verts_coords[0], verts_coords[1], parent.child_a.box):
+ edges_a.append(e)
+ if self.line_intersects_box(verts_coords[0], verts_coords[1], parent.child_b.box):
+ edges_b.append(e)
+ parent.child_a.edges = edges_a
+ parent.child_b.edges = edges_b
+ self.split_box(parent.child_a, depth + 1)
+ self.split_box(parent.child_b, depth + 1)
+
+ def raycast_box(
+ self, context: bpy.types.Context, event: bpy.types.Event, node: TreeNode, rays: tuple[Vector, Vector]
+ ) -> bool:
+ """
+ Raycast bounding box.
+
+ Args:
+ context: Blender context.
+ event: Blender event.
+ node: a TreeNode instance.
+ rays: tuple containing ray origin and ray direction
+
+ Returns:
+ True if hits the box or False otherwise.
+ """
+ box = node.box
+ min_v = box[0]
+ max_v = box[1]
+ t_min = 0.0
+ t_max = float("inf")
+ ray_origin, ray_dir = rays
+ inv_dir = Vector((1.0 / r if r != 0.0 else 1e32) for r in (ray_dir.x, ray_dir.y, ray_dir.z))
+ # X
+ tx1 = (min_v.x - ray_origin.x) * inv_dir[0]
+ tx2 = (max_v.x - ray_origin.x) * inv_dir[0]
+ tmin = min(tx1, tx2)
+ tmax = max(tx1, tx2)
+ # Y
+ ty1 = (min_v.y - ray_origin.y) * inv_dir[1]
+ ty2 = (max_v.y - ray_origin.y) * inv_dir[1]
+ tmin = max(tmin, min(ty1, ty2))
+ tmax = min(tmax, max(ty1, ty2))
+ # Z
+ tz1 = (min_v.z - ray_origin.z) * inv_dir[2]
+ tz2 = (max_v.z - ray_origin.z) * inv_dir[2]
+ tmin = max(tmin, min(tz1, tz2))
+ tmax = min(tmax, max(tz1, tz2))
+ return (tmax >= max(tmin, t_min)) and (tmin <= t_max)
+
+ def line_intersects_box(self, v1: mathutils.Vector, v2: mathutils.Vector, box: tuple) -> bool:
+ """
+ Check if a line segment intersects an axis-aligned bounding box (AABB).
+
+ Args:
+ v1: The first endpoint of the line segment as a mathutils.Vector.
+ v2: The second endpoint of the line segment as a mathutils.Vector.
+ box: A tuple containing the minimum and maximum points of the AABB, where each point is a mathutils.Vector.
+
+ Returns:
+ bool: True if the segment [v1, v2] intersects the AABB; otherwise, False.
+ """
+ bmin, bmax = box
+ dir = v2 - v1
+ tmin = 0.0
+ tmax = 1.0
+
+ for i in range(3):
+ if abs(dir[i]) < 1e-12:
+ # Line is parallel to slab. If origin not within slab -> no hit.
+ if v1[i] < bmin[i] or v1[i] > bmax[i]:
+ return False
+ else:
+ ood = 1.0 / dir[i]
+ t1 = (bmin[i] - v1[i]) * ood
+ t2 = (bmax[i] - v1[i]) * ood
+ if t1 > t2:
+ t1, t2 = t2, t1
+ if t1 > tmin:
+ tmin = t1
+ if t2 < tmax:
+ tmax = t2
+ if tmin > tmax:
+ return False
+
+ # If any overlap in [0,1] exists, there's intersection
+ return (tmax >= 0.0) and (tmin <= 1.0)
+
+ def raycast_boxes(
+ self,
+ context: bpy.types.Context,
+ event: bpy.Types.Event,
+ node: TreeNode,
+ intersected: Union[TreeNode] = [],
+ rays: tuple[Vector, Vector] = (),
+ ) -> Union[TreeNode]:
+ """
+ Raycast bounding box subdivisions recursively.
+
+ Args:
+ context: Blender context.
+ event: Blender event.
+ node: a TreeNode instance.
+ intersected: list of intersected boxes to use in recursion.
+ rays: tuple containing ray origin and ray direction
+
+ Returns:
+ tuple: a list of TreeNode instances that represent the subdivided boxes hit by the ray cast.
+ """
+ if not node.child_a:
+ intersected.append(node)
+ return intersected
+
+ intersects_a = self.raycast_box(context, event, node.child_a, rays)
+ intersects_b = self.raycast_box(context, event, node.child_b, rays)
+ if intersects_a:
+ intersected = self.raycast_boxes(context, event, node.child_a, intersected, rays)
+
+ if intersects_b:
+ intersected = self.raycast_boxes(context, event, node.child_b, intersected, rays)
+
+ return intersected
diff --git a/src/bonsai/bonsai/tool/root.py b/src/bonsai/bonsai/tool/root.py
index 02ddbe9745..8880a168fe 100644
--- a/src/bonsai/bonsai/tool/root.py
+++ b/src/bonsai/bonsai/tool/root.py
@@ -93,38 +93,16 @@ class Root(bonsai.core.tool.Root):
elif dest.is_a("IfcTypeProduct"):
if not source.RepresentationMaps:
return copied_entities
-
- # Copy representation maps while preserving mapped representation structures
- new_maps = []
- for i, rep_map in enumerate(source.RepresentationMaps):
- source_rep = rep_map.MappedRepresentation
-
- # Copy the map itself
- new_map = ifcopenshell.util.element.copy(tool.Ifc.get(), rep_map)
-
- # Handle the mapped representation - preserve mapping structure if present
- if (
- source_rep.RepresentationType == "MappedRepresentation"
- and len(source_rep.Items) == 1
- and source_rep.Items[0].is_a("IfcMappedItem")
- ):
- # This is a mapped representation - preserve the structure
- new_rep = ifcopenshell.util.element.copy(tool.Ifc.get(), source_rep)
- new_rep.Items = [ifcopenshell.util.element.copy(tool.Ifc.get(), item) for item in source_rep.Items]
- new_map.MappedRepresentation = new_rep
- else:
- # Not a mapped representation - use copy_deep as before
- new_map.MappedRepresentation = ifcopenshell.util.element.copy_deep(
- tool.Ifc.get(),
- source_rep,
- exclude=["IfcGeometricRepresentationContext"],
- exclude_callback=exclude_callback,
- copied_entities=copied_entities,
- )
-
- new_maps.append(new_map)
-
- dest.RepresentationMaps = new_maps
+ dest.RepresentationMaps = [
+ ifcopenshell.util.element.copy_deep(
+ tool.Ifc.get(),
+ m,
+ exclude=["IfcGeometricRepresentationContext"],
+ exclude_callback=exclude_callback,
+ copied_entities=copied_entities,
+ )
+ for m in source.RepresentationMaps
+ ]
return copied_entities
@classmethod
diff --git a/src/bonsai/bonsai/tool/snap.py b/src/bonsai/bonsai/tool/snap.py
index 02f484d388..5755c02ba7 100644
--- a/src/bonsai/bonsai/tool/snap.py
+++ b/src/bonsai/bonsai/tool/snap.py
@@ -388,57 +388,36 @@ class Snap(bonsai.core.tool.Snap):
# Objects
objs_to_raycast = tool.Raycast.filter_objects_to_raycast(context, event, objs_2d_bbox)
- # Wireframes
- # For wireframe we have to get all the objects so we can further calculate edge intersection
- for snap_obj in objs_to_raycast:
- if snap_obj.type in {"EMPTY", "CURVE"} or (snap_obj.type == "MESH" and len(snap_obj.data.polygons) == 0):
- snap_points = tool.Raycast.ray_cast_by_proximity(context, event, snap_obj)
- if snap_points:
- for point in snap_points:
- point["group"] = "Wireframe"
- detected_snaps.append(point)
+ closest_snaps = tool.Raycast.ray_cast_and_get_closest_to_camera_snaps(context, event, objs_to_raycast)
+ detected_snaps.extend(closest_snaps)
- if (space.shading.type == "SOLID" and space.shading.show_xray) or (
+ xray_mode = (space.shading.type == "SOLID" and space.shading.show_xray) or (
space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe
- ):
- results = []
- for obj in objs_to_raycast:
- results.append(tool.Raycast.cast_rays_to_single_object(context, event, obj))
- else:
- results = []
- results.append(tool.Raycast.cast_rays_and_get_best_object(context, event, objs_to_raycast))
+ )
- for result in results:
- snap_obj = result[0]
- hit = result[1]
- face_index = result[2]
- if hit is not None:
- # Wireframes
- if snap_obj.type in {"EMPTY", "CURVE"} or (
- snap_obj.type == "MESH" and len(snap_obj.data.polygons) == 0
- ):
- continue
- # Meshes
- else:
- # Add face snap
- snap_point = {
- "point": hit,
- "type": "Face",
- "group": "Object",
- "object": snap_obj,
- "face_index": face_index,
- "distance": 9, # High value so it has low priority
- }
- detected_snaps.append(snap_point)
-
- # Add vertex and edge snap
- snap_points = tool.Raycast.ray_cast_by_proximity(
- context, event, snap_obj, snap_obj.data.polygons[face_index]
- )
- if snap_points:
- for point in snap_points:
- point["group"] = "Object"
- detected_snaps.append(point)
+ for snap_obj in objs_to_raycast:
+ for snap in closest_snaps:
+ if snap_obj.obj == snap["object"]:
+ if xray_mode:
+ if "face_index" in snap and snap["face_index"] is not None:
+ snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
+ for point in snap_points:
+ point["group"] = "Object"
+ detected_snaps.append(point)
+ else:
+ # If it is a solid object that is closest to camera it ignores all the rest
+ if (
+ "is_closest_to_camera" in snap
+ and snap["is_closest_to_camera"]
+ and snap["group"] == "Object"
+ ):
+ closest_snap = [snap] # discards objects that aren't the closest
+ if "face_index" in snap and snap["face_index"] is not None:
+ snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
+ for point in snap_points:
+ point["group"] = "Object"
+ closest_snap.append(point)
+ detected_snaps = closest_snap
# snap to cut geometry (e.g. in plan view)
if CutDecorator.installed:
diff --git a/src/bonsai/bonsai/tool/spatial.py b/src/bonsai/bonsai/tool/spatial.py
index b185b9ab59..11a41672bc 100644
--- a/src/bonsai/bonsai/tool/spatial.py
+++ b/src/bonsai/bonsai/tool/spatial.py
@@ -21,13 +21,13 @@ from __future__ import annotations
import json
from collections import defaultdict
from collections.abc import Generator, Iterable
-from math import pi
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
import bmesh
import bpy
import ifcopenshell
import ifcopenshell.api.attribute
+import ifcopenshell.api.geometry
import ifcopenshell.api.type
import ifcopenshell.geom
import ifcopenshell.util.classification
@@ -991,114 +991,104 @@ class Spatial(bonsai.core.tool.Spatial):
return poly
@classmethod
- def get_bmesh_from_polygon(cls, poly: Polygon, h: float, polygon_is_si: bool = False) -> bmesh.types.BMesh:
- """
- :param h: Height, in meters.
- :param polygon_is_si: Should be True if `poly` is defined in meters.
- """
- mat = Matrix()
- bm = bmesh.new()
- bm.verts.index_update()
- bm.edges.index_update()
-
- mat_invert = mat.inverted()
- si_conversion = 1.0 if polygon_is_si else ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
- new_verts = [
- # bm.verts.new(mat_invert @ (Vector([v[0], v[1], 0]) * si_conversion)) for v in poly.exterior.coords[0:-1]
- bm.verts.new(mat_invert @ (Vector([v[0], v[1], 0]) * si_conversion))
- for v in shapely.get_exterior_ring(poly).coords[0:-1]
- ]
- [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)]
- bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0]))
-
- bm.verts.index_update()
- bm.edges.index_update()
-
- bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5)
- bmesh.ops.triangle_fill(bm, edges=bm.edges)
- bmesh.ops.dissolve_limit(bm, angle_limit=pi / 180 * 5, verts=bm.verts, edges=bm.edges)
-
- if h != 0:
- extrusion = bmesh.ops.extrude_face_region(bm, geom=bm.faces)
- extruded_verts = [g for g in extrusion["geom"] if isinstance(g, bmesh.types.BMVert)]
- bmesh.ops.translate(bm, vec=[0.0, 0.0, h], verts=extruded_verts)
-
- bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
-
- return bm
-
- @classmethod
- def get_named_obj_from_bmesh(cls, name: str, bmesh: bmesh.types.BMesh) -> bpy.types.Object:
- mesh = cls.get_named_mesh_from_bmesh(name, bmesh)
- obj = cls.get_named_obj_from_mesh(name, mesh)
- return obj
-
- @classmethod
- def get_named_obj_from_mesh(cls, name: str, mesh: bpy.types.Mesh) -> bpy.types.Object:
+ def create_object(cls, name: str) -> bpy.types.Object:
+ mesh = bpy.data.meshes.new(name=name)
obj = bpy.data.objects.new(name, mesh)
return obj
@classmethod
- def get_named_mesh_from_bmesh(cls, name: str, bmesh: bmesh.types.BMesh) -> bpy.types.Mesh:
- mesh = bpy.data.meshes.new(name=name)
- bmesh.to_mesh(mesh)
- bmesh.free()
- return mesh
+ def set_obj_origin_to_polygon_center(cls, obj: bpy.types.Object, poly: Polygon, polygon_is_si: bool = True) -> None:
+ unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
+ centroid = poly.centroid
+ if polygon_is_si:
+ obj.location = Vector((centroid.x, centroid.y, 0))
+ else:
+ obj.location = Vector((centroid.x * unit_scale, centroid.y * unit_scale, 0))
@classmethod
- def get_transformed_mesh_from_local_to_global(cls, mesh: bpy.types.Mesh) -> bpy.types.Mesh:
- active_obj = cls.get_active_obj()
- mat = active_obj.matrix_world
- mesh.transform(mat.inverted())
- mesh.update()
- return mesh
+ def get_2d_vertices_from_polygon(
+ cls,
+ poly: Polygon,
+ obj: bpy.types.Object,
+ polygon_is_si: bool = True,
+ ) -> list[list[float]]:
+ """Convert a world-space shapely polygon to 2D vertices in obj's local space, in IFC file units.
+
+ :param poly: The polygon in world space.
+ :param obj: The Blender object whose local space is used.
+ :param polygon_is_si: True if polygon coords are in SI, False if in IFC file units.
+ :return: List of [x, y] coordinates (not closed).
+ """
+ ifc_file = tool.Ifc.get()
+ unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
+ bpy.context.view_layer.update()
+ mat_inv = obj.matrix_world.inverted()
+ coords_2d = []
+ for v in shapely.get_exterior_ring(poly).coords[:-1]:
+ world_si = Vector((v[0], v[1], 0))
+ if not polygon_is_si:
+ world_si = world_si * unit_scale
+ local_si = mat_inv @ world_si
+ coords_2d.append([local_si.x / unit_scale, local_si.y / unit_scale])
+ return coords_2d
@classmethod
- def edit_active_space_obj_from_mesh(cls, mesh: bpy.types.Mesh) -> None:
- active_obj = bpy.context.active_object
- old_mesh = active_obj.data
- old_mesh_name = old_mesh.name
- assert active_obj and isinstance(old_mesh, bpy.types.Mesh)
- tool.Geometry.get_mesh_props(mesh).ifc_definition_id = tool.Geometry.get_mesh_props(old_mesh).ifc_definition_id
- tool.Geometry.change_object_data(active_obj, mesh, is_global=True)
- tool.Ifc.edit(active_obj)
- tool.Blender.remove_data_block(old_mesh)
- # Rename after old mesh is removed to avoid .001 suffix.
- mesh.name = old_mesh_name
+ def set_extrusion_representation_from_polygon(
+ cls,
+ obj: bpy.types.Object,
+ element: ifcopenshell.entity_instance,
+ poly: Polygon,
+ depth_ifc: float,
+ polygon_is_si: bool = True,
+ ) -> None:
+ """Create or replace the IFC body representation from a polygon extrusion.
+
+ :param obj: The Blender object.
+ :param element: The IFC product entity.
+ :param poly: The polygon in world space.
+ :param depth_ifc: The extrusion depth in IFC file units.
+ :param polygon_is_si: True if polygon coords are in SI, False if in IFC file units.
+ """
+ ifc_file = tool.Ifc.get()
+ builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
+
+ coords_2d = cls.get_2d_vertices_from_polygon(poly, obj, polygon_is_si)
+
+ curve = builder.polyline(coords_2d, closed=True)
+ item = builder.extrude(curve, magnitude=depth_ifc)
+
+ old_body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
+ if old_body:
+ context = old_body.ContextOfItems
+ ifcopenshell.api.geometry.unassign_representation(ifc_file, product=element, representation=old_body)
+ ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_body)
+ else:
+ context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
+
+ new_body = builder.get_representation(context, item)
+ ifcopenshell.api.geometry.assign_representation(ifc_file, product=element, representation=new_body)
+ bonsai.core.geometry.switch_representation(
+ tool.Ifc,
+ tool.Geometry,
+ obj=obj,
+ representation=new_body,
+ )
@classmethod
- def set_obj_origin_to_bboxcenter(cls, obj: bpy.types.Object) -> None:
- mat = obj.matrix_world
- inverted = mat.inverted()
- local_bbox_center = 0.125 * sum((Vector(b) for b in obj.bound_box), Vector())
- global_bbox_center = mat @ local_bbox_center
+ def set_space_representation_from_polygon(
+ cls,
+ obj: bpy.types.Object,
+ element: ifcopenshell.entity_instance,
+ poly: Polygon,
+ h: float,
+ polygon_is_si: bool = True,
+ ) -> None:
+ """Create or replace the IFC body representation of a space from a polygon.
- oldLoc = obj.location
- newLoc = global_bbox_center
- diff = newLoc - oldLoc
- for vert in obj.data.vertices:
- aux_vector = mat @ vert.co
- aux_vector = aux_vector - diff
- vert.co = inverted @ aux_vector
- obj.location = newLoc
-
- @classmethod
- def set_obj_origin_to_bboxcenter_and_zero_elevation(cls, obj: bpy.types.Object) -> None:
- mat = obj.matrix_world
- inverted = mat.inverted()
- local_bbox_center = 0.125 * sum((Vector(b) for b in obj.bound_box), Vector())
- global_bbox_center = mat @ local_bbox_center
- global_obj_origin = global_bbox_center
- global_obj_origin.z = 0
-
- oldLoc = obj.location
- newLoc = global_obj_origin
- diff = newLoc - oldLoc
- for vert in obj.data.vertices:
- aux_vector = mat @ vert.co
- aux_vector = aux_vector - diff
- vert.co = inverted @ aux_vector
- obj.location = newLoc
+ :param h: The height in SI (meters).
+ """
+ unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
+ cls.set_extrusion_representation_from_polygon(obj, element, poly, h / unit_scale, polygon_is_si)
@classmethod
def set_obj_origin_to_cursor_position_and_zero_elevation(cls, obj: bpy.types.Object) -> None:
@@ -1145,65 +1135,53 @@ class Spatial(bonsai.core.tool.Spatial):
if z != 0:
obj.location = obj.location + Vector((0, 0, z))
- @classmethod
- def get_2d_vertices_from_obj(cls, obj: bpy.types.Object) -> list[tuple]:
- points = []
- vectors = [v.co for v in obj.data.vertices.values()]
- for vector in vectors:
- points.append(vector.xy)
-
- points.append(vectors[0].xy)
- return points
-
- @classmethod
- def get_scaled_2d_vertices(cls, points: list[Vector]) -> list[tuple[float, float]]:
- model = tool.Ifc.get()
- unit_scale = ifcopenshell.util.unit.calculate_unit_scale(model)
- _points = []
- for p in points:
- _p = list(p)
- _p[0] /= unit_scale
- _p[1] /= unit_scale
- _points.append(_p)
- return _points
-
- @classmethod
- def assign_swept_area_outer_curve_from_2d_vertices(cls, obj: bpy.types.Object, vertices: list[Vector]) -> None:
- body = cls.get_body_representation(obj)
- model = tool.Ifc.get()
- extrusion = tool.Model.get_extrusion(body)
- area = extrusion.SweptArea
- old_area = area.OuterCurve
-
- builder = ifcopenshell.util.shape_builder.ShapeBuilder(model)
- outer_curve = builder.polyline(vertices, closed=True)
-
- area.OuterCurve = outer_curve
- ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), old_area)
-
- @classmethod
- def get_body_representation(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]:
- element = tool.Ifc.get_entity(obj)
- return ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
-
@classmethod
def assign_ifcspace_class_to_obj(cls, obj: bpy.types.Object) -> None:
- bpy.ops.bim.assign_class(obj=obj.name, ifc_class="IfcSpace")
+ bonsai.core.root.assign_class(
+ tool.Ifc,
+ tool.Collector,
+ tool.Root,
+ obj=obj,
+ ifc_class="IfcSpace",
+ should_add_representation=False,
+ )
@classmethod
def assign_type_to_obj(cls, obj: bpy.types.Object) -> None:
- # TODO this code looks in the wrong spot and suspicious
props = tool.Model.get_model_props()
ifc_file = tool.Ifc.get()
relating_type_id = props.relating_type_id
- relating_type = tool.Ifc.get().by_id(int(relating_type_id))
+ relating_type = ifc_file.by_id(int(relating_type_id))
ifc_class = relating_type.is_a()
instance_class = ifcopenshell.util.type.get_applicable_entities(ifc_class, ifc_file.schema)[0]
- bpy.ops.bim.assign_class(obj=obj.name, ifc_class=instance_class)
+ bonsai.core.root.assign_class(
+ tool.Ifc,
+ tool.Collector,
+ tool.Root,
+ obj=obj,
+ ifc_class=instance_class,
+ should_add_representation=False,
+ )
element = tool.Ifc.get_entity(obj)
assert element
ifcopenshell.api.type.assign_type(ifc_file, related_objects=[element], relating_type=relating_type)
+ @classmethod
+ def set_covering_representation_from_polygon(
+ cls,
+ obj: bpy.types.Object,
+ poly: Polygon,
+ polygon_is_si: bool = True,
+ ) -> None:
+ """Create the covering body representation from a polygon, extruded by the type's material layer thickness."""
+ element = tool.Ifc.get_entity(obj)
+ relating_type = ifcopenshell.util.element.get_type(element)
+ material = ifcopenshell.util.element.get_material(relating_type, should_skip_usage=True)
+ depth = 0.0
+ if material and material.is_a("IfcMaterialLayerSet"):
+ depth = sum(layer.LayerThickness for layer in material.MaterialLayers)
+ cls.set_extrusion_representation_from_polygon(obj, element, poly, depth, polygon_is_si)
+
@classmethod
def assign_relating_type_to_element(
cls,
@@ -1214,15 +1192,6 @@ class Spatial(bonsai.core.tool.Spatial):
) -> None:
bonsai.core.type.assign_type(ifc, tool.Model, type, element=element, type=relating_type)
- @classmethod
- def regen_obj_representation(cls, obj: bpy.types.Object, body: ifcopenshell.entity_instance) -> None:
- bonsai.core.geometry.switch_representation(
- tool.Ifc,
- tool.Geometry,
- obj=obj,
- representation=body,
- )
-
@classmethod
def set_space_visibility(cls, is_visible: bool) -> None:
if tool.Ifc.get().schema == "IFC2X3":
diff --git a/src/bonsai/bonsai/tool/type.py b/src/bonsai/bonsai/tool/type.py
index 84a349f288..882c4b4618 100644
--- a/src/bonsai/bonsai/tool/type.py
+++ b/src/bonsai/bonsai/tool/type.py
@@ -75,16 +75,7 @@ class Type(bonsai.core.tool.Type):
@classmethod
def get_model_types(cls) -> list[ifcopenshell.entity_instance]:
- ifc_file = tool.Ifc.get()
- types = ifc_file.by_type("IfcElementType")
- if tool.Ifc.get_schema() == "IFC2X3":
- types += ifc_file.by_type("IfcWindowStyle")
- types += ifc_file.by_type("IfcDoorStyle")
- types += ifc_file.by_type("IfcSpatialStructureElementType")
- else:
- types += ifc_file.by_type("IfcSpatialElementType")
- types += ifc_file.by_type("IfcTypeProduct", include_subtypes=False)
- return types
+ return tool.Ifc.get().by_type("IfcTypeProduct")
@classmethod
def get_object_data(cls, obj: bpy.types.Object) -> Union[bpy.types.ID, None]:
diff --git a/src/bonsai/bonsai/tool/unit.py b/src/bonsai/bonsai/tool/unit.py
index 5825c6f382..4c3e45b794 100644
--- a/src/bonsai/bonsai/tool/unit.py
+++ b/src/bonsai/bonsai/tool/unit.py
@@ -24,6 +24,7 @@ from typing import TYPE_CHECKING, Any, Literal, Union
import bpy
import ifcopenshell
+import ifcopenshell.util.unit
from lark import Lark, Transformer
import bonsai.bim.helper
diff --git a/src/bonsai/docs/guides/development/index.rst b/src/bonsai/docs/guides/development/index.rst
index 8852892361..d969791bf6 100644
--- a/src/bonsai/docs/guides/development/index.rst
+++ b/src/bonsai/docs/guides/development/index.rst
@@ -19,4 +19,5 @@ This chapter covers how you can help contribute to Bonsai.
undo_system
writing_docs
debugging
+ maintenance
ide/index
diff --git a/src/bonsai/docs/guides/development/maintenance.rst b/src/bonsai/docs/guides/development/maintenance.rst
new file mode 100644
index 0000000000..e1728fd7a5
--- /dev/null
+++ b/src/bonsai/docs/guides/development/maintenance.rst
@@ -0,0 +1,65 @@
+Maintenance
+===========
+
+This page documents what needs to be updated in various maintenance scenarios.
+
+Python Version Added or Removed
+--------------------------------
+
+When adding or removing a supported Python version, update the following:
+
+.. list-table::
+ :header-rows: 1
+
+ * - File
+ - What to update
+ * - ``.github/workflows/ci-black-formatting.yaml``
+ - ``MIN_IOS_PY_VERSION``
+ * - ``.github/workflows/ci-ifcopenshell-python-pypi.yml``
+ - ``pyver`` matrix
+ * - ``.github/workflows/ci-ifcopenshell-python.yml``
+ - ``pyver`` matrix
+ * - ``nix/build-all.py``
+ - ``PYTHON_VERSIONS`` list
+ * - ``src/bsdd/pyproject.toml``
+ - ``requires-python``
+ * - ``src/ifcopenshell-python/docs/ifcopenshell-python/installation.rst``
+ - add or remove the row in the ZIP packages table
+ * - ``src/ifcopenshell-python/Makefile``
+ - ``SUPPORTED_PYVERSIONS``
+ * - ``src/ifcopenshell-python/pyproject.toml``
+ - ``requires-python``
+ * - ``src/ifcopenshell-python/test/test_package.py``
+ - ``SUPPORTED_PY_VERSIONS`` tuple
+ * - ``win/build-all-win.py``
+ - ``PYTHON_VERSIONS`` list
+
+Blender Version Updated
+-----------------------
+
+When a new Blender version is released and supported:
+
+.. list-table::
+ :header-rows: 1
+
+ * - File
+ - What to update
+ * - ``.github/workflows/ci-bonsai-daily.yml``
+ - Blender download URL
+
+Blender's Bundled Python Version Updated
+-----------------------------------------
+
+When Blender ships with a new Python version:
+
+.. list-table::
+ :header-rows: 1
+
+ * - File
+ - What to update
+ * - ``.github/workflows/ci-black-formatting.yaml``
+ - ``MIN_BLENDER_PY_VERSION``
+ * - ``src/bonsai/Makefile``
+ - ``SUPPORTED_PYVERSIONS``
+ * - ``src/bonsai/scripts/dev_environment.py``
+ - ``PYTHON_VERSION`` mapping (Blender version, bundled Python version)
diff --git a/src/bonsai/scripts/bonsai_deps.py b/src/bonsai/scripts/bonsai_deps.py
new file mode 100644
index 0000000000..678fa49bb1
--- /dev/null
+++ b/src/bonsai/scripts/bonsai_deps.py
@@ -0,0 +1,23 @@
+"""Clone or update Bonsai external dependencies.
+
+Must be run from the repository root.
+"""
+
+import subprocess
+from pathlib import Path
+
+DEPS = [
+ ("https://projects.blender.org/pioverfour/sun_position.git", "sun_position"),
+ ("https://github.com/kevancress/MeasureIt_ARCH", "MeasureIt_ARCH"),
+ ("https://github.com/nortikin/sverchok.git", "sverchok"),
+]
+
+base = Path("src/bonsai/external_dependencies")
+base.mkdir(parents=True, exist_ok=True)
+
+for url, name in DEPS:
+ path = base / name
+ if not path.exists():
+ subprocess.check_call(["git", "clone", url, str(path)])
+ else:
+ subprocess.check_call(["git", "-C", str(path), "pull", "--rebase"])
diff --git a/src/bonsai/scripts/bonsai_translations.py b/src/bonsai/scripts/bonsai_translations.py
index 1597eaabcb..7b6ae960db 100644
--- a/src/bonsai/scripts/bonsai_translations.py
+++ b/src/bonsai/scripts/bonsai_translations.py
@@ -273,10 +273,10 @@ if BPY_IS_LOADED:
f"Couldn't find locale path in the source directory, creating dummy directory: {source_locale_path}.",
)
- from ui_translate.settings import (
- settings as ui_translate_settings, # pyright: ignore[reportMissingImports]
+ from ui_translate.settings import ( # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import]
+ settings as ui_translate_settings,
)
- from ui_translate.update_ui import ( # pyright: ignore[reportMissingImports]
+ from ui_translate.update_ui import ( # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import]
UI_OT_i18n_updatetranslation_init_settings,
)
diff --git a/src/bonsai/scripts/gbxml.py b/src/bonsai/scripts/gbxml.py
index 7bd8063a15..6cf104f2c1 100644
--- a/src/bonsai/scripts/gbxml.py
+++ b/src/bonsai/scripts/gbxml.py
@@ -23,7 +23,9 @@ import bpy
# sys.path.append('C:\Program Files\Python37\Lib\site-packages')
import lxml.etree
-from bspy import Gbxml # pyright: ignore[reportMissingImports]
+from bspy import ( # ty: ignore[unresolved-import]
+ Gbxml, # pyright: ignore[reportMissingImports]
+)
class GbxmlExporter:
diff --git a/src/bonsai/scripts/generate_steel_profiles_library.py b/src/bonsai/scripts/generate_steel_profiles_library.py
index 255a448127..ca45648122 100644
--- a/src/bonsai/scripts/generate_steel_profiles_library.py
+++ b/src/bonsai/scripts/generate_steel_profiles_library.py
@@ -22,7 +22,7 @@
from math import pi
from pathlib import Path
-import boltspy as bolts # pyright: ignore[reportMissingImports]
+import boltspy as bolts # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import]
import ifcopenshell.api
import ifcopenshell.api.material
import ifcopenshell.api.project
diff --git a/src/bonsai/scripts/obj2ifc-meshlab.py b/src/bonsai/scripts/obj2ifc-meshlab.py
index 70849e11d9..d707c8bda7 100644
--- a/src/bonsai/scripts/obj2ifc-meshlab.py
+++ b/src/bonsai/scripts/obj2ifc-meshlab.py
@@ -31,7 +31,7 @@ import ifcopenshell.api.spatial
import ifcopenshell.api.unit
import ifcopenshell.guid
import numpy as np
-import pymeshlab # pyright: ignore[reportMissingImports]
+import pymeshlab # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import]
class Obj2Ifc:
diff --git a/src/bonsai/scripts/obj2ifc.py b/src/bonsai/scripts/obj2ifc.py
index ec5459c4a3..0b3252cd1c 100644
--- a/src/bonsai/scripts/obj2ifc.py
+++ b/src/bonsai/scripts/obj2ifc.py
@@ -31,7 +31,7 @@ import ifcopenshell.api.spatial
import ifcopenshell.api.unit
import ifcopenshell.guid
import numpy as np
-import pywavefront # pyright: ignore[reportMissingImports]
+import pywavefront # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import]
class Obj2Ifc:
diff --git a/src/bonsai/test/bim/feature/covering.feature b/src/bonsai/test/bim/feature/covering.feature
index 4eb97649da..cefa554d41 100644
--- a/src/bonsai/test/bim/feature/covering.feature
+++ b/src/bonsai/test/bim/feature/covering.feature
@@ -2,7 +2,7 @@
Feature: Covering
Covers covering tool.
-Scenario: Execute generate flooring coverings from walls
+Scenario: Add flooring from walls
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
@@ -24,9 +24,9 @@ Scenario: Execute generate flooring coverings from walls
And the cursor is at "0,2.0,0"
And I set "scene.BIMModelProperties.length" to "1.9"
And I press "bim.add_occurrence"
- # add_instance_flooring_coverings_from_walls is expecting FLOORING predefined type.
+ # Set COV30 predefined type to FLOORING.
And the object "IfcCoveringType/COV30" is selected
- And I look at the "Attributes" panel
+ And I look at the "Object Attributes" panel
And I click "Edit"
And I set the "PredefinedType" property to "FLOORING"
And I click "Save Attributes"
@@ -42,3 +42,108 @@ Scenario: Execute generate flooring coverings from walls
Then the object "IfcCovering/Covering0" exists
And the object "IfcCovering/Covering0" is at "1.8,1.05,0.0"
And the object "IfcCovering/Covering0" dimensions are "3.4,1.9,0.03"
+
+Scenario: Add ceiling from walls
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
+ And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
+ And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
+ And I press "bim.add_occurrence"
+ And the object "IfcWall/Wall" is selected
+ And I press "bim.change_layer_length(length=3.6)"
+ And the cursor is at "3.6,0.1,3"
+ And I set "scene.BIMModelProperties.length" to "2.0"
+ And I press "bim.add_occurrence"
+ And the cursor is at "3.5,2.1,3"
+ And I set "scene.BIMModelProperties.length" to "3.5"
+ And I press "bim.add_occurrence"
+ And the cursor is at "0,2.0,0"
+ And I set "scene.BIMModelProperties.length" to "1.9"
+ And I press "bim.add_occurrence"
+ # Set COV30 predefined type to CEILING.
+ And the object "IfcCoveringType/COV30" is selected
+ And I look at the "Object Attributes" panel
+ And I click "Edit"
+ And I set the "PredefinedType" property to "CEILING"
+ And I click "Save Attributes"
+ # Run the operator with ceiling height = 2.7 (default).
+ When the object "IfcWall/Wall" is selected
+ And additionally the object "IfcWall/Wall.001" is selected
+ And additionally the object "IfcWall/Wall.002" is selected
+ And additionally the object "IfcWall/Wall.003" is selected
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcCoveringType"
+ And the variable "element_type" is "[e for e in {ifc}.by_type('IfcCoveringType') if e.Name == 'COV30'][0].id()"
+ And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
+ And I press "bim.add_instance_ceiling_coverings_from_walls"
+ Then the object "IfcCovering/Covering0" exists
+ And the object "IfcCovering/Covering0" is at "1.8,1.05,2.7"
+ And the object "IfcCovering/Covering0" dimensions are "3.4,1.9,0.03"
+
+Scenario: Add flooring from cursor
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
+ And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
+ And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
+ And I press "bim.add_occurrence"
+ And the cursor is at "1.1,0,0"
+ And I press "bim.add_occurrence"
+ And the object "IfcWall/Wall.001" is selected
+ And I press "bim.hotkey(hotkey='S_R')"
+ And the cursor is at "0,.9,0"
+ And I press "bim.add_occurrence"
+ And the cursor is at "-1,0,0"
+ And I press "bim.add_occurrence"
+ And the object "IfcWall/Wall.003" is selected
+ And I press "bim.hotkey(hotkey='S_R')"
+ And the object "IfcWall/Wall.003" is moved to "0,0,0"
+ # Set COV30 predefined type to FLOORING.
+ And the object "IfcCoveringType/COV30" is selected
+ And I look at the "Object Attributes" panel
+ And I click "Edit"
+ And I set the "PredefinedType" property to "FLOORING"
+ And I click "Save Attributes"
+ # Generate covering from cursor inside the room.
+ When the cursor is at "0.5,0.5,0"
+ And I deselect all objects
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcCoveringType"
+ And the variable "element_type" is "[e for e in {ifc}.by_type('IfcCoveringType') if e.Name == 'COV30'][0].id()"
+ And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
+ And I press "bim.add_instance_flooring_covering_from_cursor"
+ Then the object "IfcCovering/Covering" exists
+ And the object "IfcCovering/Covering" dimensions are "1,0.8,0.03"
+
+Scenario: Add ceiling from cursor
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
+ And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
+ And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
+ And I press "bim.add_occurrence"
+ And the cursor is at "1.1,0,0"
+ And I press "bim.add_occurrence"
+ And the object "IfcWall/Wall.001" is selected
+ And I press "bim.hotkey(hotkey='S_R')"
+ And the cursor is at "0,.9,0"
+ And I press "bim.add_occurrence"
+ And the cursor is at "-1,0,0"
+ And I press "bim.add_occurrence"
+ And the object "IfcWall/Wall.003" is selected
+ And I press "bim.hotkey(hotkey='S_R')"
+ And the object "IfcWall/Wall.003" is moved to "0,0,0"
+ # Set COV30 predefined type to CEILING.
+ And the object "IfcCoveringType/COV30" is selected
+ And I look at the "Object Attributes" panel
+ And I click "Edit"
+ And I set the "PredefinedType" property to "CEILING"
+ And I click "Save Attributes"
+ # Generate covering from cursor inside the room.
+ When the cursor is at "0.5,0.5,0"
+ And I deselect all objects
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcCoveringType"
+ And the variable "element_type" is "[e for e in {ifc}.by_type('IfcCoveringType') if e.Name == 'COV30'][0].id()"
+ And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
+ And I press "bim.add_instance_ceiling_covering_from_cursor"
+ Then the object "IfcCovering/Covering" exists
+ And the object "IfcCovering/Covering" dimensions are "1,0.8,0.03"
diff --git a/src/bonsai/test/bim/feature/drawing.feature b/src/bonsai/test/bim/feature/drawing.feature
index b7b78e748d..0f0c5a3b17 100644
--- a/src/bonsai/test/bim/feature/drawing.feature
+++ b/src/bonsai/test/bim/feature/drawing.feature
@@ -310,9 +310,129 @@ Scenario: Create sheet - with a drawing added to it
When I click "OUTPUT"
Then the file "{ifc_dir}/sheets/A01 - UNTITLED.svg" should contain "IfcWall"
+Scenario: Enable editing text
+ Given an empty IFC project
+ And I add a cube
+ And the object "Cube" is selected
+ And I save IFC project
+ And I look at the "Drawings" panel
+ And I click "IMPORT"
+ And I click "ADD"
+ And I press "bim.toggle_target_view(option="EXPAND", target_view='PLAN_VIEW')"
+ And I select the "PLAN_VIEW" item in the "BIM_UL_drawinglist" list
+ And I click "VIEW_CAMERA_UNSELECTED" in the row where I see "PLAN_VIEW" in the "1st" list
+ And I press "bim.add_annotation"
+ And the object "IfcAnnotation/TEXT" is selected
+ And I look at the "BIM_PT_text" panel
+ When I click "Enable Editing Text"
+ Then I see "Literals:"
+ And I don't see "FontSize"
+
+Scenario: Disable editing text
+ Given an empty IFC project
+ And I add a cube
+ And the object "Cube" is selected
+ And I save IFC project
+ And I look at the "Drawings" panel
+ And I click "IMPORT"
+ And I click "ADD"
+ And I press "bim.toggle_target_view(option="EXPAND", target_view='PLAN_VIEW')"
+ And I select the "PLAN_VIEW" item in the "BIM_UL_drawinglist" list
+ And I click "VIEW_CAMERA_UNSELECTED" in the row where I see "PLAN_VIEW" in the "1st" list
+ And I press "bim.add_annotation"
+ And the object "IfcAnnotation/TEXT" is selected
+ And I look at the "BIM_PT_text" panel
+ And I click "Enable Editing Text"
+ When I click "CANCEL"
+ Then I see "FontSize"
+ And I don't see "Literals:"
+
+Scenario: Edit text - no changes
+ Given an empty IFC project
+ And I add a cube
+ And the object "Cube" is selected
+ And I save IFC project
+ And I look at the "Drawings" panel
+ And I click "IMPORT"
+ And I click "ADD"
+ And I press "bim.toggle_target_view(option="EXPAND", target_view='PLAN_VIEW')"
+ And I select the "PLAN_VIEW" item in the "BIM_UL_drawinglist" list
+ And I click "VIEW_CAMERA_UNSELECTED" in the row where I see "PLAN_VIEW" in the "1st" list
+ And I press "bim.add_annotation"
+ And the object "IfcAnnotation/TEXT" is selected
+ And I look at the "BIM_PT_text" panel
+ And I click "Enable Editing Text"
+ When I click "Edit Text"
+ Then I see "FontSize"
+ And I don't see "Literals:"
+
+Scenario: Edit text - change literal
+ Given an empty IFC project
+ And I add a cube
+ And the object "Cube" is selected
+ And I save IFC project
+ And I look at the "Drawings" panel
+ And I click "IMPORT"
+ And I click "ADD"
+ And I press "bim.toggle_target_view(option="EXPAND", target_view='PLAN_VIEW')"
+ And I select the "PLAN_VIEW" item in the "BIM_UL_drawinglist" list
+ And I click "VIEW_CAMERA_UNSELECTED" in the row where I see "PLAN_VIEW" in the "1st" list
+ And I press "bim.add_annotation"
+ And the object "IfcAnnotation/TEXT" is selected
+ And I look at the "BIM_PT_text" panel
+ And I click "Enable Editing Text"
+ And I set the "Literal" property to "Hello World"
+ When I click "Edit Text"
+ Then I see "Hello World"
+
+Scenario: Add text literal
+ Given an empty IFC project
+ And I add a cube
+ And the object "Cube" is selected
+ And I save IFC project
+ And I look at the "Drawings" panel
+ And I click "IMPORT"
+ And I click "ADD"
+ And I press "bim.toggle_target_view(option="EXPAND", target_view='PLAN_VIEW')"
+ And I select the "PLAN_VIEW" item in the "BIM_UL_drawinglist" list
+ And I click "VIEW_CAMERA_UNSELECTED" in the row where I see "PLAN_VIEW" in the "1st" list
+ And I press "bim.add_annotation"
+ And the object "IfcAnnotation/TEXT" is selected
+ And I look at the "BIM_PT_text" panel
+ And I click "Enable Editing Text"
+ And I click the "ADD" after the text "Literals:"
+ And I set the "2nd Literal" property to "New Literal"
+ When I click "Edit Text"
+ Then I see "New Literal"
+
+Scenario: Remove text literal
+ Given an empty IFC project
+ And I add a cube
+ And the object "Cube" is selected
+ And I save IFC project
+ And I look at the "Drawings" panel
+ And I click "IMPORT"
+ And I click "ADD"
+ And I press "bim.toggle_target_view(option="EXPAND", target_view='PLAN_VIEW')"
+ And I select the "PLAN_VIEW" item in the "BIM_UL_drawinglist" list
+ And I click "VIEW_CAMERA_UNSELECTED" in the row where I see "PLAN_VIEW" in the "1st" list
+ And I press "bim.add_annotation"
+ And the object "IfcAnnotation/TEXT" is selected
+ And I look at the "BIM_PT_text" panel
+ And I click "Enable Editing Text"
+ And I set the "Literal" property to "Keep This"
+ And I click the "ADD" after the text "Literals:"
+ And I set the "2nd Literal" property to "Remove This"
+ And I click "Edit Text"
+ And I click "Enable Editing Text"
+ When I click the "2nd" "X"
+ And I click "Edit Text"
+ Then I see "Keep This"
+ And I don't see "Remove This"
+
Scenario: Add reference image
Given an empty IFC project
And I save IFC project
- When I press "bim.add_reference_image(filepath='{cwd}/test/files/image.jpg')"
+ When I press "bim.add_reference_image(filepath='{cwd}/test/files/image.jpg', x_length=1, y_length=0.565)"
Then the object "IfcAnnotation/image" exists
And the object "IfcAnnotation/image" dimensions are "1.0,0.565,0."
diff --git a/src/bonsai/test/bim/feature/material.feature b/src/bonsai/test/bim/feature/material.feature
index 2f34cbb893..67525cd8f3 100644
--- a/src/bonsai/test/bim/feature/material.feature
+++ b/src/bonsai/test/bim/feature/material.feature
@@ -422,6 +422,24 @@ Scenario: Enable editing material set item
When I press "bim.enable_editing_material_set_item(material_set_item={material_profile})"
Then nothing happens
+Scenario: Edit layer item defaults null IsVentilated to FALSE in UI
+ Given an empty IFC project
+ And I add a cube
+ And the object "Cube" is selected
+ And I look at the "Class" panel
+ And I set the "Products" property to "IfcElement"
+ And I set the "Class" property to "IfcWall"
+ And I click "Assign IFC Class"
+ And I press "bim.add_material()"
+ And I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialLayerSet"
+ And I press "bim.assign_material"
+ And I press "bim.enable_editing_assigned_material"
+ And the variable "layer" is "{ifc}.by_type('IfcMaterialLayer')[0].id()"
+ And I press "bim.enable_editing_material_set_item(material_set_item={layer})"
+ When I evaluate expression "attrs = bpy.context.active_object.BIMObjectMaterialProperties.material_set_item_attributes; is_vent = next(a for a in attrs if a.name == 'IsVentilated'); assert is_vent.enum_value == 'FALSE'; assert is_vent.is_null is True"
+ And I press "bim.edit_material_set_item(material_set_item={layer})"
+ Then I evaluate expression "assert {ifc}.by_id({layer}).IsVentilated is None"
+
Scenario: Add material set layer
Given an empty IFC project
And I add a cube
diff --git a/src/bonsai/test/bim/feature/model.feature b/src/bonsai/test/bim/feature/model.feature
index 9ca843c1c5..064620a574 100644
--- a/src/bonsai/test/bim/feature/model.feature
+++ b/src/bonsai/test/bim/feature/model.feature
@@ -57,7 +57,7 @@ Scenario: Add one type from the Construction Type Browser
Scenario: Add grid
Given an empty IFC project
- When I press "mesh.add_grid"
+ When I press "bim.add_grid"
Then the object "IfcGrid/Grid" is an "IfcGrid"
And the object "IfcGridAxis/A" is an "IfcGridAxis"
And the object "IfcGridAxis/B" is an "IfcGridAxis"
@@ -396,6 +396,44 @@ Scenario: Add a slab
And the object "IfcSlab/Slab" bottom left corner is at "0,0,0"
And the object "IfcSlab/Slab" top right corner is at "1,1,0.2"
+Scenario: Extend walls to underside
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
+ And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
+ And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
+ And I press "bim.add_occurrence"
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcSlabType"
+ And the variable "element_type" is "[e for e in {ifc}.by_type('IfcSlabType') if e.Name == 'FLR200'][0].id()"
+ And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
+ And I press "bim.add_occurrence"
+ And the object "IfcSlab/Slab" is moved to "0,0,2.5"
+ When the object "IfcWall/Wall" is selected
+ And additionally the object "IfcSlab/Slab" is selected
+ And I look at the tool header
+ And I click "Extend To Underside"
+ Then the object "IfcWall/Wall" dimensions are "1,0.1,2.5"
+
+Scenario: Extend walls to underside - extending to a tessellated gable roof
+ Given an empty IFC project
+ And I load the demo construction library
+ And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
+ And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
+ And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
+ And I press "bim.add_occurrence"
+ # Create gable roof: a cube turned into a prism with a ridge.
+ And I add a cube of size "1" at "0.5,0.05,3"
+ And the object "Cube" is selected
+ And I evaluate expression "obj = bpy.context.active_object; [setattr(v.co, 'y', 0) for v in obj.data.vertices if v.co.z > 0]"
+ And I set "scene.BIMRootProperties.ifc_product" to "IfcElement"
+ And I set "scene.BIMRootProperties.ifc_class" to "IfcRoof"
+ And I press "bim.assign_class"
+ When the object "IfcWall/Wall" is selected
+ And additionally the object "IfcRoof/Cube" is selected
+ And I look at the tool header
+ And I click "Extend To Underside"
+ Then the object "IfcWall/Wall" dimensions are "1,0.1,2.5"
+
Scenario: Enable editing a slab profile
Given an empty IFC project
And I load the demo construction library
diff --git a/src/bonsai/test/bim/feature/project.feature b/src/bonsai/test/bim/feature/project.feature
index f536a17220..0db6041b56 100644
--- a/src/bonsai/test/bim/feature/project.feature
+++ b/src/bonsai/test/bim/feature/project.feature
@@ -914,7 +914,7 @@ Scenario: Export IFC - with moved object location synchronised
Scenario: Export IFC - with moved grid axis location synchronised
Given an empty IFC project
- And I press "mesh.add_grid"
+ And I press "bim.add_grid"
When the object "IfcGridAxis/01" is moved to "1,0,0"
And I save IFC project
And I load previously saved IFC project
diff --git a/src/bonsai/test/bim/test_feature.py b/src/bonsai/test/bim/test_feature.py
index 262dce65ed..79e8494441 100644
--- a/src/bonsai/test/bim/test_feature.py
+++ b/src/bonsai/test/bim/test_feature.py
@@ -133,7 +133,11 @@ class PanelSpy:
self.spied_labels.append(kwargs["text"])
return self
elif self.spied_attr == "prop":
- props, name = args
+ if args:
+ props, name = args
+ else:
+ props = kwargs.get("data")
+ name = kwargs.get("property")
props: bpy.types.bpy_struct
text = kwargs.get("text", props.bl_rna.properties[name].name)
icon = kwargs.get("icon", None)
@@ -242,7 +246,7 @@ class TemplateListItemSpy(PanelSpy):
self.spied_props: list[dict[str, Any]] = []
self.spied_operators: list[dict[str, Any]] = []
if len(signature(blender_panel.draw_item).parameters) == 8:
- blender_panel.draw_item(
+ blender_panel.draw_item( # ty:ignore[missing-argument]
self,
bpy.context,
self,
@@ -378,7 +382,7 @@ def i_look_at_the_panel_panel(panel: str) -> None:
# Option to provide explicit panel name if panel names overlap.
panel_class = getattr(bpy.types, panel, None)
- if panel_class is None:
+ if panel_class is None or panel_class.bl_rna.base.name not in ("Panel", "Operator", "Menu", "UIList"):
global ui_name_cache
create_ui_name_cache()
if panel not in ui_name_cache:
@@ -390,6 +394,32 @@ def i_look_at_the_panel_panel(panel: str) -> None:
panel_spy.refresh_spy()
+@given(parsers.parse("I look at the tool header"))
+@when(parsers.parse("I look at the tool header"))
+@then(parsers.parse("I look at the tool header"))
+def i_look_at_the_tool_header() -> None:
+ from bonsai.bim.module.model.workspace import EditObjectUI
+
+ class MockRegion:
+ type = "UI"
+
+ class MockContext:
+ def __getattr__(self, name):
+ if name == "region":
+ return MockRegion()
+ return getattr(bpy.context, name)
+
+ global panel_spy
+ panel_spy = PanelSpy(EditObjectUI)
+ panel_spy.is_spy_dirty = False
+ panel_spy.spied_attr = None
+ panel_spy.spied_labels = []
+ panel_spy.spied_props = []
+ panel_spy.spied_operators = []
+ panel_spy.spied_lists = []
+ EditObjectUI.draw(MockContext(), panel_spy)
+
+
@given(parsers.parse('I open the "{name}" menu'))
@when(parsers.parse('I open the "{name}" menu'))
@then(parsers.parse('I open the "{name}" menu'))
@@ -610,8 +640,9 @@ def i_see_the_prop_property_is_value(prop, value):
@then(parsers.parse('I set the "{prop}" property to "{value}"'))
def i_set_the_prop_property_to_value(prop: str, value: str):
"""
- :param prop: Could be either property name, property text, property icon
- or property index (e.g. "1st", "2nd", "5th").
+ :param prop: Could be either property name, property text, property icon,
+ property index (e.g. "1st", "2nd", "5th"), or Nth named property
+ (e.g. "2nd Literal" for the 2nd property called "Literal").
:param value:
For boolean propeties - 'TRUE' or 'FALSE'.
"""
@@ -619,12 +650,28 @@ def i_set_the_prop_property_to_value(prop: str, value: str):
assert panel_spy
panel_spy.refresh_spy()
is_nth = False
- if prop[0].isnumeric() and prop.endswith(("st", "nd", "th")):
+ is_nth_named = False
+ nth_target = 0
+ prop_name = prop
+ if " " in prop and prop[0].isnumeric():
+ parts = prop.split(" ", 1)
+ if parts[0].endswith(("st", "nd", "th")):
+ is_nth_named = True
+ nth_target = int(parts[0][:-2]) - 1
+ prop_name = parts[1]
+ elif prop[0].isnumeric() and prop.endswith(("st", "nd", "th")):
is_nth = True
+ named_count = 0
for nth, spied_prop in enumerate(panel_spy.spied_props):
if is_nth and nth != int(prop[:-2]) - 1:
continue
- if not is_nth and prop not in (spied_prop["name"], spied_prop["text"], spied_prop["icon"]):
+ if is_nth_named:
+ if prop_name not in (spied_prop["name"], spied_prop["text"], spied_prop["icon"]):
+ continue
+ if named_count != nth_target:
+ named_count += 1
+ continue
+ elif not is_nth and prop not in (spied_prop["name"], spied_prop["text"], spied_prop["icon"]):
continue
if spied_prop["prop_type"] == "BOOLEAN":
if value == "TRUE":
@@ -873,6 +920,29 @@ def i_click_button(button):
_i_click_button_on_panel(button, panel_spy)
+@given(parsers.parse('I click the "{nth}" "{button}"'))
+@when(parsers.parse('I click the "{nth}" "{button}"'))
+@then(parsers.parse('I click the "{nth}" "{button}"'))
+def i_click_the_nth_button(nth, button):
+ """
+ :param nth: Ordinal like "1st", "2nd", "3rd" to select the Nth matching button.
+ :param button: The text or icon of the button to click.
+ """
+ assert panel_spy
+ panel_spy.refresh_spy()
+ target = int(nth[:-2]) - 1
+ count = 0
+ for spied_operator in panel_spy.spied_operators:
+ if spied_operator["text"] == button or spied_operator["icon"] == button:
+ if count == target:
+ spied_operator["operator"]("INVOKE_DEFAULT", **spied_operator["kwargs"])
+ panel_spy.is_spy_dirty = True
+ return
+ count += 1
+ debug = "\n".join([f"{i} {v}" for i, v in enumerate(panel_spy.spied_operators)])
+ assert False, f"Could not find {nth} {button}:\n{debug}"
+
+
@given(parsers.parse('I click the "{button}" after the text "{text}"'))
@when(parsers.parse('I click the "{button}" after the text "{text}"'))
@then(parsers.parse('I click the "{button}" after the text "{text}"'))
diff --git a/src/bonsai/test/core/bootstrap.py b/src/bonsai/test/core/bootstrap.py
index 057ee9ffab..cd8371e1c3 100644
--- a/src/bonsai/test/core/bootstrap.py
+++ b/src/bonsai/test/core/bootstrap.py
@@ -102,6 +102,13 @@ def geometry():
prophet.verify()
+@pytest.fixture
+def ifcgit():
+ prophet = Prophecy(bonsai.core.tool.IfcGit)
+ yield prophet
+ prophet.verify()
+
+
@pytest.fixture
def georeference():
prophet = Prophecy(bonsai.core.tool.Georeference)
diff --git a/src/bonsai/test/core/test_drawing.py b/src/bonsai/test/core/test_drawing.py
index a83f026ad3..52822aa754 100644
--- a/src/bonsai/test/core/test_drawing.py
+++ b/src/bonsai/test/core/test_drawing.py
@@ -35,9 +35,14 @@ class TestDisableEditingText:
class TestEditText:
def test_run(self, drawing):
- drawing.synchronise_ifc_and_text_attributes("obj").should_be_called()
- drawing.update_text_size_pset("obj").should_be_called()
- drawing.update_text_annotation_properties("obj").should_be_called()
+ drawing.export_text_literal_attributes("obj").should_be_called().will_return("literal_attributes")
+ drawing.export_font_size("obj").should_be_called().will_return("font_size")
+ drawing.edit_text_font_size("obj", "font_size").should_be_called()
+ drawing.export_wrap_length("obj").should_be_called().will_return("wrap_length")
+ drawing.edit_text_wrap_length("obj", "wrap_length").should_be_called()
+ drawing.export_symbol("obj").should_be_called().will_return("symbol")
+ drawing.edit_text_symbol("obj", "symbol").should_be_called()
+ drawing.edit_text_literals("obj", "literal_attributes").should_be_called()
drawing.disable_editing_text("obj").should_be_called()
subject.edit_text(drawing, obj="obj")
@@ -466,6 +471,7 @@ class TestRemoveDrawing:
class TestUpdateDrawingName:
def test_do_not_update_if_name_unchanged(self, ifc, drawing):
drawing.get_name("drawing").should_be_called().will_return("name")
+ drawing.set_camera_name("drawing", "name").should_be_called()
drawing.get_drawing_group("drawing").should_be_called().will_return("group")
drawing.get_name("group").should_be_called().will_return("name")
drawing.get_drawing_collection("drawing").should_be_called().will_return("collection")
@@ -482,6 +488,7 @@ class TestUpdateDrawingName:
def test_run(self, ifc, drawing):
drawing.get_name("drawing").should_be_called().will_return("oldname")
ifc.run("attribute.edit_attributes", product="drawing", attributes={"Name": "name"}).should_be_called()
+ drawing.set_camera_name("drawing", "name").should_be_called()
drawing.get_drawing_group("drawing").should_be_called().will_return("group")
drawing.get_name("group").should_be_called().will_return("oldname")
ifc.run("attribute.edit_attributes", product="group", attributes={"Name": "name"}).should_be_called()
diff --git a/src/bonsai/test/core/test_georeference.py b/src/bonsai/test/core/test_georeference.py
index b9b330d816..e95236ec3f 100644
--- a/src/bonsai/test/core/test_georeference.py
+++ b/src/bonsai/test/core/test_georeference.py
@@ -23,6 +23,7 @@ from test.core.bootstrap import georeference, ifc
class TestAddGeoreferencing:
def test_run(self, georeference):
georeference.add_georeferencing().should_be_called()
+ georeference.set_model_origin().should_be_called()
subject.add_georeferencing(georeference)
@@ -35,9 +36,10 @@ class TestEnableEditingGeoreferencing:
class TestRemoveGeoreferencing:
- def test_run(self, ifc):
+ def test_run(self, ifc, georeference):
ifc.run("georeference.remove_georeferencing").should_be_called()
- subject.remove_georeferencing(ifc)
+ georeference.set_model_origin().should_be_called()
+ subject.remove_georeferencing(ifc, georeference)
class TestDisableEditingGeoreferencing:
diff --git a/src/bonsai/test/core/test_ifcgit.py b/src/bonsai/test/core/test_ifcgit.py
new file mode 100644
index 0000000000..b4d80985b2
--- /dev/null
+++ b/src/bonsai/test/core/test_ifcgit.py
@@ -0,0 +1,274 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2025 Dion Moult
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+# This file was generated with the assistance of an AI coding tool.
+
+import pytest
+
+import bonsai.core.ifcgit as subject
+from test.core.bootstrap import ifcgit, ifc
+
+
+class MockOperator:
+ def __init__(self):
+ self.reports = []
+
+ def report(self, level, message):
+ self.reports.append((level, message))
+
+
+class TestCreateRepo:
+ def test_run(self, ifcgit, ifc):
+ ifc.get_path().should_be_called().will_return("path/to/model.ifc")
+ ifcgit.get_path_dir("path/to/model.ifc").should_be_called().will_return("path/to")
+ ifcgit.init_repo("path/to").should_be_called()
+ subject.create_repo(ifcgit, ifc)
+
+
+class TestAddFile:
+ def test_run(self, ifcgit, ifc):
+ ifc.get_path().should_be_called().will_return("path/to/model.ifc")
+ ifcgit.repo_from_path("path/to/model.ifc").should_be_called().will_return("repo")
+ ifcgit.add_file_to_repo("repo", "path/to/model.ifc").should_be_called()
+ subject.add_file(ifcgit, ifc)
+
+
+class TestCloneRepo:
+ def test_successful_clone(self, ifcgit):
+ ifcgit.clone_repo("http://example.com/repo.git", "/local/folder").should_be_called().will_return("repo")
+ ifcgit.load_anyifc("repo").should_be_called()
+ op = MockOperator()
+ subject.clone_repo(ifcgit, "http://example.com/repo.git", "/local/folder", operator=op)
+ assert op.reports == [({"INFO"}, "Repository cloned")]
+
+ def test_failed_clone_reports_error(self, ifcgit):
+ ifcgit.clone_repo("http://example.com/repo.git", "/local/folder").should_be_called().will_return(None)
+ op = MockOperator()
+ subject.clone_repo(ifcgit, "http://example.com/repo.git", "/local/folder", operator=op)
+ assert op.reports == [({"ERROR"}, "Clone failed")]
+
+
+class TestDiscardUncommitted:
+ def test_run(self, ifcgit, ifc):
+ ifc.get_path().should_be_called().will_return("path/to/model.ifc")
+ ifcgit.git_checkout("path/to/model.ifc").should_be_called()
+ ifcgit.load_project("path/to/model.ifc").should_be_called()
+ subject.discard_uncommitted(ifcgit, ifc)
+
+
+class TestCommitChanges:
+ def test_commit_on_branch_without_new_branch(self, ifcgit, ifc):
+ ifc.get_path().should_be_called().will_return("path/to/model.ifc")
+ ifcgit.is_head_detached().should_be_called().will_return(False)
+ ifcgit.git_commit("path/to/model.ifc", "my message").should_be_called()
+ subject.commit_changes(ifcgit, ifc, "my message", "")
+
+ def test_commit_on_branch_with_new_branch(self, ifcgit, ifc):
+ ifc.get_path().should_be_called().will_return("path/to/model.ifc")
+ ifcgit.is_head_detached().should_be_called().will_return(False)
+ ifcgit.checkout_new_branch("path/to/model.ifc", "feature").should_be_called()
+ ifcgit.git_commit("path/to/model.ifc", "my message").should_be_called()
+ subject.commit_changes(ifcgit, ifc, "my message", "feature")
+
+ def test_commit_on_detached_head(self, ifcgit, ifc):
+ ifc.get_path().should_be_called().will_return("path/to/model.ifc")
+ ifcgit.is_head_detached().should_be_called().will_return(True)
+ ifcgit.git_commit("path/to/model.ifc", "my message").should_be_called()
+ ifcgit.create_new_branch("feature").should_be_called()
+ subject.commit_changes(ifcgit, ifc, "my message", "feature")
+
+
+class TestAddTag:
+ def test_run(self, ifcgit):
+ ifcgit.add_tag("repo", "abc123", "v1.0", "Release notes").should_be_called()
+ subject.add_tag(ifcgit, "repo", "abc123", "v1.0", "Release notes")
+
+
+class TestDeleteTag:
+ def test_run(self, ifcgit):
+ ifcgit.delete_tag("repo", "v1.0").should_be_called()
+ subject.delete_tag(ifcgit, "repo", "v1.0")
+
+
+class TestAddRemote:
+ def test_run(self, ifcgit):
+ ifcgit.add_remote("repo", "origin", "http://example.com").should_be_called()
+ subject.add_remote(ifcgit, "repo", "origin", "http://example.com")
+
+
+class TestDeleteRemote:
+ def test_run(self, ifcgit):
+ ifcgit.delete_remote("repo", "origin").should_be_called()
+ subject.delete_remote(ifcgit, "repo", "origin")
+
+
+class TestPush:
+ def test_push_succeeds_silently(self, ifcgit):
+ ifcgit.get_active_branch_name().should_be_called().will_return("main")
+ ifcgit.push("repo", "origin", "main").should_be_called().will_return(None)
+ subject.push(ifcgit, "repo", "origin", operator=None)
+
+ def test_push_failure_reports_error(self, ifcgit):
+ ifcgit.get_active_branch_name().should_be_called().will_return("main")
+ ifcgit.push("repo", "origin", "main").should_be_called().will_return("stderr: rejected")
+ op = MockOperator()
+ subject.push(ifcgit, "repo", "origin", operator=op)
+ assert op.reports == [({"ERROR"}, "stderr: rejected")]
+
+
+class TestRefreshRevisionList:
+ def test_refreshes_when_repo_has_heads(self, ifcgit, ifc):
+ ifcgit.repo_has_commits().should_be_called().will_return(True)
+ ifc.get_path().should_be_called().will_return("path/to/model.ifc")
+ ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called()
+ subject.refresh_revision_list(ifcgit, ifc)
+
+ def test_skips_when_repo_has_no_heads(self, ifcgit, ifc):
+ ifcgit.repo_has_commits().should_be_called().will_return(False)
+ subject.refresh_revision_list(ifcgit, ifc)
+ # nothing else should be called — Prophecy will verify
+
+
+class TestColouriseRevision:
+ def test_skips_when_no_step_ids(self, ifcgit):
+ ifcgit.get_revisions_step_ids().should_be_called().will_return(None)
+ subject.colourise_revision(ifcgit)
+
+ def test_colourises_with_step_ids(self, ifcgit):
+ ifcgit.get_revisions_step_ids().should_be_called().will_return("step_ids")
+ ifcgit.get_modified_step_ids("step_ids").should_be_called().will_return("modified_step_ids")
+ ifcgit.update_step_ids("step_ids", "modified_step_ids").should_be_called().will_return("final_step_ids")
+ ifcgit.colourise("final_step_ids").should_be_called()
+ subject.colourise_revision(ifcgit)
+
+
+class TestColouriseUncommitted:
+ def test_skips_when_no_step_ids(self, ifcgit, ifc):
+ ifc.get_path().should_be_called().will_return("path/to/model.ifc")
+ ifcgit.ifc_diff_ids("repo", None, "HEAD", "path/to/model.ifc").should_be_called().will_return(None)
+ subject.colourise_uncommitted(ifcgit, ifc, "repo")
+
+ def test_colourises_with_step_ids(self, ifcgit, ifc):
+ ifc.get_path().should_be_called().will_return("path/to/model.ifc")
+ ifcgit.ifc_diff_ids("repo", None, "HEAD", "path/to/model.ifc").should_be_called().will_return("step_ids")
+ ifcgit.get_modified_step_ids("step_ids").should_be_called().will_return("modified_step_ids")
+ ifcgit.update_step_ids("step_ids", "modified_step_ids").should_be_called().will_return("final_step_ids")
+ ifcgit.colourise("final_step_ids").should_be_called()
+ subject.colourise_uncommitted(ifcgit, ifc, "repo")
+
+
+class TestSwitchRevision:
+ def test_run(self, ifcgit, ifc):
+ ifc.get_path().should_be_called().will_return("path/to/model.ifc")
+ ifcgit.switch_to_revision_item().should_be_called()
+ ifcgit.load_project("path/to/model.ifc").should_be_called()
+ ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called()
+ ifcgit.decolourise().should_be_called()
+ subject.switch_revision(ifcgit, ifc)
+
+
+class TestMergeBranch:
+ def test_no_branch_at_selected_commit(self, ifcgit, ifc):
+ ifc.get_path().should_be_called().will_return("path/to/model.ifc")
+ ifcgit.config_ifcmerge().should_be_called()
+ ifcgit.get_selected_branch().should_be_called().will_return(None)
+ subject.merge_branch(ifcgit, ifc, operator=None)
+
+ def test_clean_merge(self, ifcgit, ifc):
+ ifc.get_path().should_be_called().will_return("path/to/model.ifc")
+ ifcgit.config_ifcmerge().should_be_called()
+ ifcgit.get_selected_branch().should_be_called().will_return("feature")
+ ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward")
+ ifcgit.git_merge("feature").should_be_called().will_return(None)
+ ifcgit.set_display_branch().should_be_called()
+ ifcgit.load_project("path/to/model.ifc").should_be_called()
+ ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called()
+ ifcgit.decolourise().should_be_called()
+ subject.merge_branch(ifcgit, ifc, operator=None)
+
+ def test_conflict_mergetool_success(self, ifcgit, ifc):
+ ifc.get_path().should_be_called().will_return("path/to/model.ifc")
+ ifcgit.config_ifcmerge().should_be_called()
+ ifcgit.get_selected_branch().should_be_called().will_return("feature")
+ ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward")
+ ifcgit.git_merge("feature").should_be_called().will_return("conflict")
+ ifcgit.git_mergetool("ifcmerge-forward").should_be_called().will_return(None)
+ ifcgit.commit_merge("path/to/model.ifc").should_be_called()
+ ifcgit.set_display_branch().should_be_called()
+ ifcgit.load_project("path/to/model.ifc").should_be_called()
+ ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called()
+ ifcgit.decolourise().should_be_called()
+ subject.merge_branch(ifcgit, ifc, operator=None)
+
+ def test_conflict_mergetool_failure(self, ifcgit, ifc):
+ ifc.get_path().should_be_called().will_return("path/to/model.ifc")
+ ifcgit.config_ifcmerge().should_be_called()
+ ifcgit.get_selected_branch().should_be_called().will_return("feature")
+ ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward")
+ ifcgit.git_merge("feature").should_be_called().will_return("conflict")
+ ifcgit.git_mergetool("ifcmerge-forward").should_be_called().will_return("merge error")
+ ifcgit.git_merge_abort().should_be_called()
+ op = MockOperator()
+ subject.merge_branch(ifcgit, ifc, op)
+ assert op.reports == [({"ERROR"}, "IFC Merge failed:merge error")]
+
+ def test_unknown_merge_error(self, ifcgit, ifc):
+ ifc.get_path().should_be_called().will_return("path/to/model.ifc")
+ ifcgit.config_ifcmerge().should_be_called()
+ ifcgit.get_selected_branch().should_be_called().will_return("feature")
+ ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward")
+ ifcgit.git_merge("feature").should_be_called().will_return("error")
+ op = MockOperator()
+ subject.merge_branch(ifcgit, ifc, op)
+ assert op.reports == [({"ERROR"}, "Unknown IFC Merge failure")]
+
+
+class TestEntityLog:
+ def test_run(self, ifcgit, ifc):
+ ifc.get_path().should_be_called().will_return("path/to/model.ifc")
+ ifcgit.entity_log("path/to/model.ifc", 42).should_be_called().will_return("log text")
+ op = MockOperator()
+ subject.entity_log(ifcgit, ifc, 42, op)
+ assert op.reports == [({"ERROR"}, "log text")]
+
+
+class TestInstallGit:
+ def test_windows(self, ifcgit):
+ import unittest.mock as mock
+
+ with mock.patch("platform.system", return_value="Windows"):
+ ifcgit.install_git_windows(operator="op").should_be_called()
+ subject.install_git(ifcgit, "op")
+
+ def test_non_windows_does_nothing(self, ifcgit):
+ import unittest.mock as mock
+
+ with mock.patch("platform.system", return_value="Linux"):
+ subject.install_git(ifcgit, "op")
+ # no tool method should be called — Prophecy will verify
+
+
+class TestFetch:
+ def test_run(self, ifcgit):
+ ifcgit.fetch("origin").should_be_called()
+ subject.fetch(ifcgit, "origin")
+
+
+class TestRunGitDiff:
+ def test_run(self, ifcgit):
+ ifcgit.run_git_diff("operator", False).should_be_called()
+ subject.run_git_diff(ifcgit, "operator", False)
diff --git a/src/bonsai/test/core/test_type.py b/src/bonsai/test/core/test_type.py
index e46cf8168f..031e414f95 100644
--- a/src/bonsai/test/core/test_type.py
+++ b/src/bonsai/test/core/test_type.py
@@ -22,8 +22,9 @@ from test.core.bootstrap import geometry, ifc, model, type
class TestAssignType:
def test_assigning_and_switching_to_an_existing_type_data(self, ifc, model, type):
+ type.record_material_usage_attributes("element").should_be_called().will_return(None)
ifc.run("type.assign_type", related_objects=["element"], relating_type="type").should_be_called()
- type.has_material_usage("element").should_be_called().will_return(False)
+ model.get_usage_type("type").should_be_called(2).will_return(None)
ifc.get_object("type").should_be_called().will_return("type_obj")
type.get_object_data("type_obj").should_be_called().will_return("type_obj_data")
type.change_object_data("obj", "type_obj_data", is_global=False).should_be_called()
@@ -31,9 +32,10 @@ class TestAssignType:
type.disable_editing("obj").should_be_called()
subject.assign_type(ifc, model, type, element="element", type="type")
- def test_assigning_and_not_changing_data_if_the_type_has_no_data(self, ifc, type):
+ def test_assigning_and_not_changing_data_if_the_type_has_no_data(self, ifc, model, type):
+ type.record_material_usage_attributes("element").should_be_called().will_return(None)
ifc.run("type.assign_type", related_objects=["element"], relating_type="type").should_be_called()
- type.has_material_usage("element").should_be_called().will_return(False)
+ model.get_usage_type("type").should_be_called(2).will_return(None)
ifc.get_object("type").should_be_called().will_return("type_obj")
type.get_object_data("type_obj").should_be_called().will_return(None)
ifc.get_object("element").should_be_called().will_return("obj")
diff --git a/src/bonsai/test/tool/test_aggregate.py b/src/bonsai/test/tool/test_aggregate.py
index bab71b2c4a..4fd9388c13 100644
--- a/src/bonsai/test/tool/test_aggregate.py
+++ b/src/bonsai/test/tool/test_aggregate.py
@@ -18,6 +18,7 @@
import bpy
import ifcopenshell
+import ifcopenshell.api.aggregate
import ifcopenshell.api.context
import ifcopenshell.api.geometry
import ifcopenshell.api.root
@@ -99,6 +100,42 @@ class TestCanAggregate(NewFile):
subelement_obj = bpy.data.objects.new("Object", None)
assert subject.can_aggregate(element_obj, subelement_obj) is False
+ def test_element_cannot_aggregate_to_itself(self):
+ ifc = ifcopenshell.file()
+ tool.Ifc.set(ifc)
+ element = ifc.createIfcElementAssembly()
+ element_obj = bpy.data.objects.new("Object", None)
+ tool.Ifc.link(element, element_obj)
+ assert subject.can_aggregate(element_obj, element_obj) is False
+
+ def test_cyclic_aggregation_is_prevented(self):
+ ifc = ifcopenshell.file()
+ tool.Ifc.set(ifc)
+ assembly_a = ifc.createIfcElementAssembly()
+ assembly_a_obj = bpy.data.objects.new("AssemblyA", None)
+ tool.Ifc.link(assembly_a, assembly_a_obj)
+ beam = ifc.createIfcBeam()
+ beam_obj = bpy.data.objects.new("Beam", None)
+ tool.Ifc.link(beam, beam_obj)
+ ifcopenshell.api.aggregate.assign_object(ifc, products=[beam], relating_object=assembly_a)
+ assert subject.can_aggregate(beam_obj, assembly_a_obj) is False
+
+ def test_deep_cyclic_aggregation_is_prevented(self):
+ ifc = ifcopenshell.file()
+ tool.Ifc.set(ifc)
+ assembly_a = ifc.createIfcElementAssembly()
+ assembly_a_obj = bpy.data.objects.new("AssemblyA", None)
+ tool.Ifc.link(assembly_a, assembly_a_obj)
+ assembly_b = ifc.createIfcElementAssembly()
+ assembly_b_obj = bpy.data.objects.new("AssemblyB", None)
+ tool.Ifc.link(assembly_b, assembly_b_obj)
+ beam = ifc.createIfcBeam()
+ beam_obj = bpy.data.objects.new("Beam", None)
+ tool.Ifc.link(beam, beam_obj)
+ ifcopenshell.api.aggregate.assign_object(ifc, products=[assembly_b], relating_object=assembly_a)
+ ifcopenshell.api.aggregate.assign_object(ifc, products=[beam], relating_object=assembly_b)
+ assert subject.can_aggregate(beam_obj, assembly_a_obj) is False
+
class TestHasPhysicalBodyRepresentation(NewFile):
def test_run(self):
diff --git a/src/bonsai/test/tool/test_blender.py b/src/bonsai/test/tool/test_blender.py
index cd155b5dee..cb5d06bc71 100644
--- a/src/bonsai/test/tool/test_blender.py
+++ b/src/bonsai/test/tool/test_blender.py
@@ -24,6 +24,7 @@ import bpy
import ifcopenshell
import pytest
+import bonsai
import bonsai.core.tool
import bonsai.tool as tool
from bonsai.tool.blender import Blender as subject
@@ -144,3 +145,25 @@ class TestGetSelectedFiles(NewFile):
assert subject.get_selected_files(Path(g.name).parent, [file], use_relative_path=True) == [
Path(g.name).name
]
+
+
+class TestGetDebugInfo(NewFile):
+ # Only keys that are safe to set if Bonsai fails to load.
+ EXPECTED_KEYS = {
+ "os",
+ "os_version",
+ "python_version",
+ "architecture",
+ "machine",
+ "processor",
+ "blender_version",
+ "bonsai_version",
+ "bonsai_commit_hash",
+ "bonsai_commit_date",
+ "last_actions",
+ "last_error",
+ }
+
+ def test_failed_to_load_returns_only_base_keys(self):
+ info = bonsai.get_debug_info(bonsai_failed_to_load=True)
+ assert set(info.keys()) == self.EXPECTED_KEYS
diff --git a/src/bonsai/test/tool/test_classification.py b/src/bonsai/test/tool/test_classification.py
index 7bfe2a44d7..a48df50db4 100644
--- a/src/bonsai/test/tool/test_classification.py
+++ b/src/bonsai/test/tool/test_classification.py
@@ -42,6 +42,7 @@ class TestAddClassificationReferenceFromBSDD(NewFile):
bpy.ops.mesh.primitive_cube_add(size=10, location=(0, 0, 4))
obj = bpy.data.objects["Cube"]
bpy.ops.bim.assign_class(ifc_class="IfcSpace", predefined_type="SPACE", userdefined_type="")
+ tool.Blender.set_active_object(obj)
element = tool.Ifc.get_entity(obj)
assert element
@@ -66,6 +67,7 @@ class TestAddClassificationReferenceFromBSDD(NewFile):
bpy.ops.mesh.primitive_cube_add(size=10, location=(0, 0, 4))
obj = bpy.data.objects["Cube"]
bpy.ops.bim.assign_class(ifc_class="IfcSpace", predefined_type="SPACE", userdefined_type="")
+ tool.Blender.set_active_object(obj)
element = tool.Ifc.get_entity(obj)
assert element
@@ -110,6 +112,7 @@ class TestAddClassificationReferenceFromBSDD(NewFile):
bpy.ops.mesh.primitive_cube_add(size=10, location=(0, 0, 4))
obj = bpy.data.objects["Cube"]
bpy.ops.bim.assign_class(ifc_class="IfcSpace", predefined_type="SPACE", userdefined_type="")
+ tool.Blender.set_active_object(obj)
element = tool.Ifc.get_entity(obj)
assert element
diff --git a/src/bonsai/test/tool/test_drawing.py b/src/bonsai/test/tool/test_drawing.py
index 06091ff8c4..e3de6ba2d9 100644
--- a/src/bonsai/test/tool/test_drawing.py
+++ b/src/bonsai/test/tool/test_drawing.py
@@ -666,10 +666,13 @@ class TestImportTextAttributes(NewFile):
literal_props = props.literals[0]
assert literal_props.ifc_definition_id == item.id()
- assert literal_props.box_alignment[:] == tuple([False] * 6 + [True] + [False] * 2)
assert literal_props.attributes["Literal"].string_value == "Literal"
assert literal_props.attributes["Path"].enum_value == "RIGHT"
assert literal_props.attributes["BoxAlignment"].string_value == "bottom-left"
+ assert literal_props.align_vertical == "bottom"
+ assert literal_props.align_horizontal == "left"
+ assert props.align_vertical == "bottom"
+ assert props.align_horizontal == "left"
class TestReplaceTextLiteralVariables(NewFile):
@@ -957,4 +960,4 @@ class TestAddReferenceImage(NewFile):
assert texture_filepath == filepath
uv_node = material_nodes["Texture Coordinate"]
- assert len(uv_node.outputs["UV"].links[:]) == 1
+ assert len(uv_node.outputs["Generated"].links[:]) == 1
diff --git a/src/bonsai/test/tool/test_ifcgit.py b/src/bonsai/test/tool/test_ifcgit.py
new file mode 100644
index 0000000000..5cd051aa5f
--- /dev/null
+++ b/src/bonsai/test/tool/test_ifcgit.py
@@ -0,0 +1,456 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2025 Dion Moult
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+# This file was generated with the assistance of an AI coding tool.
+
+import os
+import tempfile
+
+import bonsai.core.tool
+from bonsai.tool.ifcgit import IfcGit, IfcGitRepo
+from test.bim.bootstrap import NewFile
+
+try:
+ import git
+ import git.exc
+
+ HAS_GIT = True
+except ImportError:
+ HAS_GIT = False
+
+import pytest
+
+requires_git = pytest.mark.skipif(not HAS_GIT, reason="GitPython not available")
+
+
+def _make_repo(tmpdir: str) -> "git.Repo":
+ """Initialise a git repo with user config (needed for commits)."""
+ repo = git.Repo.init(tmpdir)
+ with repo.config_writer() as cfg:
+ cfg.set_value("user", "name", "Test User")
+ cfg.set_value("user", "email", "test@example.com")
+ return repo
+
+
+def _commit_ifc(repo: "git.Repo", tmpdir: str, content: str, message: str) -> str:
+ """Write content to model.ifc, stage and commit it. Returns commit hexsha."""
+ ifc_path = os.path.join(tmpdir, "model.ifc")
+ with open(ifc_path, "w") as f:
+ f.write(content)
+ repo.index.add([os.path.normpath(ifc_path)])
+ commit = repo.index.commit(message)
+ return commit.hexsha
+
+
+# ---------------------------------------------------------------------------
+# Interface conformance
+# ---------------------------------------------------------------------------
+
+
+class TestImplementsTool(NewFile):
+ def test_run(self):
+ assert isinstance(IfcGit(), bonsai.core.tool.IfcGit)
+
+
+# ---------------------------------------------------------------------------
+# Pure-logic (no git required)
+# ---------------------------------------------------------------------------
+
+
+class TestIsValidRefFormat(NewFile):
+ def test_simple_name(self):
+ assert IfcGit.is_valid_ref_format("main")
+
+ def test_name_with_slash(self):
+ assert IfcGit.is_valid_ref_format("feature/my-feature")
+
+ def test_name_with_numbers(self):
+ assert IfcGit.is_valid_ref_format("release-2024")
+
+ def test_rejects_leading_dot(self):
+ assert not IfcGit.is_valid_ref_format(".hidden")
+
+ def test_rejects_leading_dash(self):
+ assert not IfcGit.is_valid_ref_format("-branch")
+
+ def test_rejects_space(self):
+ assert not IfcGit.is_valid_ref_format("my branch")
+
+ def test_rejects_double_dot(self):
+ assert not IfcGit.is_valid_ref_format("my..branch")
+
+ def test_rejects_tilde(self):
+ assert not IfcGit.is_valid_ref_format("my~branch")
+
+ def test_rejects_caret(self):
+ assert not IfcGit.is_valid_ref_format("my^branch")
+
+ def test_rejects_trailing_dot(self):
+ assert not IfcGit.is_valid_ref_format("branch.")
+
+ def test_rejects_trailing_slash(self):
+ assert not IfcGit.is_valid_ref_format("branch/")
+
+ def test_rejects_dot_lock_suffix(self):
+ assert not IfcGit.is_valid_ref_format("branch.lock")
+
+ def test_rejects_empty_string(self):
+ assert not IfcGit.is_valid_ref_format("")
+
+ def test_rejects_at_brace(self):
+ assert not IfcGit.is_valid_ref_format("branch@{upstream}")
+
+
+class TestGetPathDir(NewFile):
+ def test_returns_parent_directory(self):
+ assert IfcGit.get_path_dir("/some/path/model.ifc") == "/some/path"
+
+ def test_handles_nested_path(self):
+ assert IfcGit.get_path_dir("/a/b/c/d.ifc") == "/a/b/c"
+
+ def test_returns_absolute_path(self):
+ result = IfcGit.get_path_dir("/a/b/model.ifc")
+ assert os.path.isabs(result)
+
+
+# ---------------------------------------------------------------------------
+# File I/O
+# ---------------------------------------------------------------------------
+
+
+class TestDos2Unix(NewFile):
+ def test_converts_crlf_to_lf(self):
+ with tempfile.NamedTemporaryFile(suffix=".ifc", delete=False, mode="wb") as f:
+ f.write(b"line1\r\nline2\r\nline3\r\n")
+ path = f.name
+ try:
+ IfcGit.dos2unix(path)
+ with open(path, "rb") as f:
+ assert f.read() == b"line1\nline2\nline3\n"
+ finally:
+ os.unlink(path)
+
+ def test_lf_only_file_is_unchanged(self):
+ with tempfile.NamedTemporaryFile(suffix=".ifc", delete=False, mode="wb") as f:
+ f.write(b"line1\nline2\nline3\n")
+ path = f.name
+ try:
+ IfcGit.dos2unix(path)
+ with open(path, "rb") as f:
+ assert f.read() == b"line1\nline2\nline3\n"
+ finally:
+ os.unlink(path)
+
+ def test_empty_file_unchanged(self):
+ with tempfile.NamedTemporaryFile(suffix=".ifc", delete=False, mode="wb") as f:
+ f.write(b"")
+ path = f.name
+ try:
+ IfcGit.dos2unix(path)
+ with open(path, "rb") as f:
+ assert f.read() == b""
+ finally:
+ os.unlink(path)
+
+
+# ---------------------------------------------------------------------------
+# Git repo initialisation
+# ---------------------------------------------------------------------------
+
+
+class TestInitRepo(NewFile):
+ @requires_git
+ def test_creates_git_repo(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ IfcGitRepo.repo = None
+ IfcGit.init_repo(tmpdir)
+ assert IfcGitRepo.repo is not None
+ assert os.path.isdir(IfcGitRepo.repo.git_dir)
+ IfcGitRepo.repo = None
+
+ @requires_git
+ def test_creates_info_attributes_file(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ IfcGitRepo.repo = None
+ IfcGit.init_repo(tmpdir)
+ attrs_path = os.path.join(IfcGitRepo.repo.git_dir, "info", "attributes")
+ assert os.path.isfile(attrs_path)
+ with open(attrs_path) as f:
+ assert "*.ifc text" in f.read()
+ IfcGitRepo.repo = None
+
+
+class TestRepoFromPath(NewFile):
+ @requires_git
+ def test_finds_repo_from_file_in_root(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ IfcGitRepo.repo = None
+ repo = _make_repo(tmpdir)
+ ifc_path = os.path.join(tmpdir, "model.ifc")
+ open(ifc_path, "w").close()
+ result = IfcGit.repo_from_path(ifc_path)
+ assert result is not None
+ assert os.path.abspath(result.working_dir) == os.path.abspath(tmpdir)
+ IfcGitRepo.repo = None
+
+ @requires_git
+ def test_finds_repo_from_subdirectory(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ IfcGitRepo.repo = None
+ _make_repo(tmpdir)
+ subdir = os.path.join(tmpdir, "sub", "dir")
+ os.makedirs(subdir)
+ result = IfcGit.repo_from_path(subdir)
+ assert result is not None
+ IfcGitRepo.repo = None
+
+ @requires_git
+ def test_returns_none_for_path_outside_any_repo(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ IfcGitRepo.repo = None
+ # a plain directory with no .git anywhere above it (using /tmp directly
+ # is safe since /tmp is not a git repo on this system)
+ result = IfcGit.repo_from_path("/nonexistent/path/that/does/not/exist")
+ assert result is None
+ IfcGitRepo.repo = None
+
+
+# ---------------------------------------------------------------------------
+# Git repo configuration
+# ---------------------------------------------------------------------------
+
+
+class TestConfigInfoAttributes(NewFile):
+ @requires_git
+ def test_creates_attributes_file(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ repo = _make_repo(tmpdir)
+ IfcGit.config_info_attributes(repo)
+ attrs_path = os.path.join(repo.git_dir, "info", "attributes")
+ assert os.path.isfile(attrs_path)
+ with open(attrs_path) as f:
+ assert "*.ifc text" in f.read()
+
+ @requires_git
+ def test_does_not_overwrite_existing_attributes(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ repo = _make_repo(tmpdir)
+ attrs_path = os.path.join(repo.git_dir, "info", "attributes")
+ os.makedirs(os.path.dirname(attrs_path), exist_ok=True)
+ with open(attrs_path, "w") as f:
+ f.write("*.png binary\n")
+ IfcGit.config_info_attributes(repo)
+ with open(attrs_path) as f:
+ content = f.read()
+ assert "*.png binary" in content # original content preserved
+
+
+class TestConfigPush(NewFile):
+ @requires_git
+ def test_sets_push_defaults(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ repo = _make_repo(tmpdir)
+ IfcGit.config_push(repo)
+ reader = repo.config_reader()
+ assert reader.get_value("push", "default") == "current"
+ assert reader.get_value("push", "autoSetupRemote") is True
+
+ @requires_git
+ def test_does_not_overwrite_existing_push_section(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ repo = _make_repo(tmpdir)
+ with repo.config_writer() as w:
+ w.set_value("push", "default", "simple")
+ IfcGit.config_push(repo)
+ reader = repo.config_reader()
+ assert reader.get_value("push", "default") == "simple"
+
+
+# ---------------------------------------------------------------------------
+# Branch / tag lookups
+# ---------------------------------------------------------------------------
+
+
+class TestBranchesByHexsha(NewFile):
+ @requires_git
+ def test_maps_head_commit_to_active_branch(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ repo = _make_repo(tmpdir)
+ _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
+ result = IfcGit.branches_by_hexsha(repo)
+ head_sha = repo.head.commit.hexsha
+ assert head_sha in result
+ names = [b.name for b in result[head_sha]]
+ assert repo.active_branch.name in names
+
+ @requires_git
+ def test_returns_empty_dict_when_no_commits(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ repo = _make_repo(tmpdir)
+ result = IfcGit.branches_by_hexsha(repo)
+ assert result == {}
+
+ @requires_git
+ def test_includes_both_branches_when_pointing_to_same_commit(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ repo = _make_repo(tmpdir)
+ _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
+ repo.create_head("feature")
+ result = IfcGit.branches_by_hexsha(repo)
+ head_sha = repo.head.commit.hexsha
+ names = [b.name for b in result[head_sha]]
+ assert len(names) == 2
+
+
+class TestTagsByHexsha(NewFile):
+ @requires_git
+ def test_returns_empty_dict_when_no_tags(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ repo = _make_repo(tmpdir)
+ _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
+ assert IfcGit.tags_by_hexsha(repo) == {}
+
+ @requires_git
+ def test_maps_commit_to_annotated_tag(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ repo = _make_repo(tmpdir)
+ _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
+ repo.create_tag("v1.0", message="Release 1.0")
+ result = IfcGit.tags_by_hexsha(repo)
+ head_sha = repo.head.commit.hexsha
+ assert head_sha in result
+ assert result[head_sha][0].name == "v1.0"
+
+ @requires_git
+ def test_maps_commit_to_lightweight_tag(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ repo = _make_repo(tmpdir)
+ _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
+ repo.create_tag("v0.1")
+ result = IfcGit.tags_by_hexsha(repo)
+ head_sha = repo.head.commit.hexsha
+ assert head_sha in result
+
+
+class TestDeleteTag(NewFile):
+ @requires_git
+ def test_removes_existing_tag(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ repo = _make_repo(tmpdir)
+ _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
+ repo.create_tag("v1.0")
+ IfcGit.delete_tag(repo, "v1.0")
+ assert "v1.0" not in [t.name for t in repo.tags]
+
+ @requires_git
+ def test_does_not_raise_for_nonexistent_tag(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ repo = _make_repo(tmpdir)
+ _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
+ IfcGit.delete_tag(repo, "does-not-exist") # must not raise
+
+
+# ---------------------------------------------------------------------------
+# IFC diff parsing
+# ---------------------------------------------------------------------------
+
+
+class TestIfcDiffIds(NewFile):
+ @requires_git
+ def test_detects_modified_entity(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ repo = _make_repo(tmpdir)
+ sha_a = _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
+ ifc_path = os.path.join(tmpdir, "model.ifc")
+ sha_b = _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('xyz',$,$,$,$,$,$,$,$);\n", "update")
+ result = IfcGit.ifc_diff_ids(repo, sha_a, sha_b, ifc_path)
+ assert 1 in result["modified"]
+ assert result["added"] == set()
+ assert result["removed"] == set()
+
+ @requires_git
+ def test_detects_added_entity(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ repo = _make_repo(tmpdir)
+ sha_a = _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
+ ifc_path = os.path.join(tmpdir, "model.ifc")
+ sha_b = _commit_ifc(
+ repo,
+ tmpdir,
+ "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n#2=IFCSITE('new',$,$,$,$,$,$,$,$,$,$,$,$);\n",
+ "add site",
+ )
+ result = IfcGit.ifc_diff_ids(repo, sha_a, sha_b, ifc_path)
+ assert 2 in result["added"]
+ assert 1 not in result["modified"]
+ assert result["removed"] == set()
+
+ @requires_git
+ def test_detects_removed_entity(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ repo = _make_repo(tmpdir)
+ sha_a = _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
+ ifc_path = os.path.join(tmpdir, "model.ifc")
+ sha_b = _commit_ifc(repo, tmpdir, "", "clear")
+ result = IfcGit.ifc_diff_ids(repo, sha_a, sha_b, ifc_path)
+ assert 1 in result["removed"]
+ assert result["added"] == set()
+ assert result["modified"] == set()
+
+ @requires_git
+ def test_no_changes_between_identical_commits(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ repo = _make_repo(tmpdir)
+ sha = _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
+ ifc_path = os.path.join(tmpdir, "model.ifc")
+ result = IfcGit.ifc_diff_ids(repo, sha, sha, ifc_path)
+ assert result["modified"] == set()
+ assert result["added"] == set()
+ assert result["removed"] == set()
+
+ @requires_git
+ def test_diff_against_working_tree_with_none_hash_a(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ repo = _make_repo(tmpdir)
+ _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
+ ifc_path = os.path.join(tmpdir, "model.ifc")
+ # Make an uncommitted change in working tree
+ with open(ifc_path, "w") as f:
+ f.write("#1=IFCPROJECT('xyz',$,$,$,$,$,$,$,$);\n")
+ result = IfcGit.ifc_diff_ids(repo, None, "HEAD", ifc_path)
+ assert 1 in result["modified"]
+
+ @requires_git
+ def test_handles_multiple_changed_entities(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ repo = _make_repo(tmpdir)
+ sha_a = _commit_ifc(
+ repo,
+ tmpdir,
+ "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n#2=IFCSITE('s',$,$,$,$,$,$,$,$,$,$,$,$);\n",
+ "init",
+ )
+ ifc_path = os.path.join(tmpdir, "model.ifc")
+ sha_b = _commit_ifc(
+ repo,
+ tmpdir,
+ "#1=IFCPROJECT('xyz',$,$,$,$,$,$,$,$);\n#2=IFCSITE('t',$,$,$,$,$,$,$,$,$,$,$,$);\n",
+ "update both",
+ )
+ result = IfcGit.ifc_diff_ids(repo, sha_a, sha_b, ifc_path)
+ assert 1 in result["modified"]
+ assert 2 in result["modified"]
diff --git a/src/bonsai/test/tool/test_misc.py b/src/bonsai/test/tool/test_misc.py
index c3d3cec2fe..21c1fd341c 100644
--- a/src/bonsai/test/tool/test_misc.py
+++ b/src/bonsai/test/tool/test_misc.py
@@ -171,6 +171,12 @@ class TestScaleObjectToHeight(test.bim.bootstrap.NewFile):
assert list(obj.scale) == [1.0, 1.0, 1.0]
+class TestQuickFavoritesOffsetUserMenus(test.bim.bootstrap.NewFile):
+ def test_current_blender_version_is_supported(self):
+ version = bpy.app.version[:2]
+ assert version in subject.QuickFavorites.OFFSET_USER_MENUS
+
+
class TestSplitObjectsWithCutter(test.bim.bootstrap.NewFile):
def test_run(self):
bpy.ops.mesh.primitive_cube_add()
diff --git a/src/bonsai/test/tool/test_model.py b/src/bonsai/test/tool/test_model.py
index 756268cbee..30782b8a15 100644
--- a/src/bonsai/test/tool/test_model.py
+++ b/src/bonsai/test/tool/test_model.py
@@ -176,6 +176,7 @@ class TestStairCalculatedParams(NewFile):
pset_data = pset_data_base.copy()
calculated_data = calculated_data_base.copy()
pset_data["custom_first_last_tread_run"] = (0.1, 0.4)
+ pset_data["custom_tread_lock"] = False
calculated_data["Length"] += -0.2 + 0.1
self.compare_data(pset_data, calculated_data)
@@ -183,6 +184,7 @@ class TestStairCalculatedParams(NewFile):
pset_data = pset_data_base.copy()
calculated_data = calculated_data_base.copy()
pset_data["custom_first_last_tread_run"] = (0.0, None)
+ pset_data["custom_tread_lock"] = False
calculated_data["Length"] = 0.9 # Only 3 treads at 0.3 each
self.compare_data(pset_data, calculated_data)
@@ -190,6 +192,7 @@ class TestStairCalculatedParams(NewFile):
pset_data = pset_data_base.copy()
calculated_data = calculated_data_base.copy()
pset_data["custom_first_last_tread_run"] = (None, 0.0)
+ pset_data["custom_tread_lock"] = False
calculated_data["Length"] = 0.9 # Only 3 treads at 0.3 each
self.compare_data(pset_data, calculated_data)
@@ -197,6 +200,7 @@ class TestStairCalculatedParams(NewFile):
pset_data = pset_data_base.copy()
calculated_data = calculated_data_base.copy()
pset_data["custom_first_last_tread_run"] = (0.0, 0.0)
+ pset_data["custom_tread_lock"] = False
calculated_data["Length"] = 0.6 # Only 2 middle treads at 0.3 each
self.compare_data(pset_data, calculated_data)
diff --git a/src/bonsai/test/tool/test_nest.py b/src/bonsai/test/tool/test_nest.py
index 14acfa18bc..368ea9a923 100644
--- a/src/bonsai/test/tool/test_nest.py
+++ b/src/bonsai/test/tool/test_nest.py
@@ -19,6 +19,7 @@
import bpy
import ifcopenshell
import ifcopenshell.api
+import ifcopenshell.api.nest
import ifcopenshell.api.spatial
import bonsai.core.tool
@@ -51,6 +52,42 @@ class TestCanNest(NewFile):
subelement_obj = bpy.data.objects.new("Object", None)
assert subject.can_nest(element_obj, subelement_obj) is False
+ def test_element_cannot_nest_to_itself(self):
+ ifc = ifcopenshell.file()
+ tool.Ifc.set(ifc)
+ element = ifc.createIfcWall()
+ element_obj = bpy.data.objects.new("Object", None)
+ tool.Ifc.link(element, element_obj)
+ assert subject.can_nest(element_obj, element_obj) is False
+
+ def test_cyclic_nesting_is_prevented(self):
+ ifc = ifcopenshell.file()
+ tool.Ifc.set(ifc)
+ wall_a = ifc.createIfcWall()
+ wall_a_obj = bpy.data.objects.new("WallA", None)
+ tool.Ifc.link(wall_a, wall_a_obj)
+ wall_b = ifc.createIfcWall()
+ wall_b_obj = bpy.data.objects.new("WallB", None)
+ tool.Ifc.link(wall_b, wall_b_obj)
+ ifcopenshell.api.nest.assign_object(ifc, related_objects=[wall_b], relating_object=wall_a)
+ assert subject.can_nest(wall_b_obj, wall_a_obj) is False
+
+ def test_deep_cyclic_nesting_is_prevented(self):
+ ifc = ifcopenshell.file()
+ tool.Ifc.set(ifc)
+ wall_a = ifc.createIfcWall()
+ wall_a_obj = bpy.data.objects.new("WallA", None)
+ tool.Ifc.link(wall_a, wall_a_obj)
+ wall_b = ifc.createIfcWall()
+ wall_b_obj = bpy.data.objects.new("WallB", None)
+ tool.Ifc.link(wall_b, wall_b_obj)
+ wall_c = ifc.createIfcWall()
+ wall_c_obj = bpy.data.objects.new("WallC", None)
+ tool.Ifc.link(wall_c, wall_c_obj)
+ ifcopenshell.api.nest.assign_object(ifc, related_objects=[wall_b], relating_object=wall_a)
+ ifcopenshell.api.nest.assign_object(ifc, related_objects=[wall_c], relating_object=wall_b)
+ assert subject.can_nest(wall_c_obj, wall_a_obj) is False
+
class TestDisableEditing(NewFile):
def test_run(self):
diff --git a/src/bonsai/test/tool/test_project.py b/src/bonsai/test/tool/test_project.py
index c6d1c410de..e9e11f4656 100644
--- a/src/bonsai/test/tool/test_project.py
+++ b/src/bonsai/test/tool/test_project.py
@@ -365,7 +365,7 @@ class TestLoadingIfcSqlite(NewFile):
sql_type="SQLite",
)
patcher.patch()
- tmp_file = Path(tempfile.mktemp(suffix=".ifcsqlite"))
+ tmp_file = Path(tempfile.mkstemp(suffix=".ifcsqlite")[1])
ifcpatch.write(patcher.get_output(), tmp_file)
elements_with_meshes = [
@@ -403,13 +403,6 @@ class TestLoadingIfcSqlite(NewFile):
class TestGettingLinkedElementGeomSlice:
- def __init__(self):
- self.test_get_first_element()
- self.test_get_middle_element()
- self.test_skip_hidden_first_element()
- self.test_skip_hidden_middle_element()
- self.test_handle_hidden_non_first_element()
-
TEST_OBJ = {
"guids": ["aaa", "bbb", "ccc"],
"guid_ids": [5, 10, 15],
diff --git a/src/bonsai/test/tool/test_system.py b/src/bonsai/test/tool/test_system.py
index 3067ccfa09..5c18ad623f 100644
--- a/src/bonsai/test/tool/test_system.py
+++ b/src/bonsai/test/tool/test_system.py
@@ -23,6 +23,7 @@ import ifcopenshell
import ifcopenshell.api
import ifcopenshell.api.root
import ifcopenshell.api.system
+import ifcopenshell.util.system
import ifcopenshell.util.unit
import numpy as np
from mathutils import Euler, Matrix, Vector
diff --git a/src/bonsai/type-check-requirements.txt b/src/bonsai/type-check-requirements.txt
new file mode 100644
index 0000000000..1a8419c7d3
--- /dev/null
+++ b/src/bonsai/type-check-requirements.txt
@@ -0,0 +1,41 @@
+aiohttp
+beautifulsoup4
+boto3
+botocore
+brickschema
+cjio >=0.8, <0.10
+debugpy
+ezdxf
+fake-bpy-module-latest
+git+https://github.com/prochitecture/bpypolyskel
+git+https://github.com/Andrej730/IFC2JSON_python.git@pyproject_toml
+gitpython
+isodate
+lark
+lxml
+lxml-stubs
+markdown-it-py
+natsort
+numpy
+odfpy
+openpyxl
+pandas
+pillow
+platformdirs
+pygments
+pyradiance
+pystache
+pytest
+pytest_bdd
+pytest_blender
+python-dateutil
+python-socketio
+pytz
+rdflib
+requests
+shapely
+svgwrite
+typing-extensions
+typst
+tzfpy
+xsdata
diff --git a/src/bsdd/bsdd_json.py b/src/bsdd/bsdd_json.py
index becaac810b..6a89d19753 100644
--- a/src/bsdd/bsdd_json.py
+++ b/src/bsdd/bsdd_json.py
@@ -7,7 +7,7 @@ from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator
-from .type_hints import *
+from type_hints import *
def _lower_first(s: str) -> str:
diff --git a/src/common.mk b/src/common.mk
index 24537d06de..765655339b 100644
--- a/src/common.mk
+++ b/src/common.mk
@@ -28,6 +28,7 @@ dist:
mkdir -p dist
cp -r $(PACKAGE_NAME) build/
cp pyproject.toml build/
+ if [ -f README.md ]; then cp README.md build/; fi
ifeq ($(IS_STABLE), TRUE)
$(SED) 's/version = "0.0.0"/version = "$(VERSION)"/' build/pyproject.toml
ifdef IS_MODULE
diff --git a/src/ifcchat/README.md b/src/ifcchat/README.md
new file mode 100644
index 0000000000..f20aa8788d
--- /dev/null
+++ b/src/ifcchat/README.md
@@ -0,0 +1,10 @@
+IfcOpenShell AI Assistant
+=========================
+
+A web-based client-side (pyodide + OpenAI API) model interrogation and generation API based on: ifcedit, ifcquery and ifcmcp (ifcopenshell-mcp) packaged in a HTML+JS application.
+
+### Setup instructions
+
+```
+pip download ifcquery==0.8.5 ifcopenshell-mcp==0.8.5 ifcedit==0.8.5 lark==1.3.1 isodate==0.7.2 --no-deps -d ./dist
+```
diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js
new file mode 100644
index 0000000000..516ec8f846
--- /dev/null
+++ b/src/ifcchat/app.js
@@ -0,0 +1,320 @@
+// app.js
+const $ = (id) => document.getElementById(id);
+
+const statusEl = $("status");
+const msgsEl = $("msgs");
+const sendBtn = $("send");
+const inputEl = $("input");
+const apiKeyEl = $("apiKey");
+const modelEl = $("model");
+const ifcFileEl = $("ifcFile");
+const newBtn = $("newModel");
+const downloadBtn = $("downloadIfc");
+
+function setBusy(isBusy, reason = "") {
+ const controls = [
+ $("send"),
+ $("newModel"),
+ $("downloadIfc"),
+ $("ifcFile"),
+ ];
+
+ for (const el of controls) el.disabled = isBusy;
+
+ $("input").disabled = isBusy;
+
+ const browseBtn = $("browseBtn");
+ if (browseBtn) {
+ browseBtn.classList.toggle("disabled", isBusy);
+ browseBtn.setAttribute("aria-disabled", isBusy ? "true" : "false");
+ browseBtn.tabIndex = isBusy ? -1 : 0;
+ }
+
+ setStatus(isBusy ? (reason || "Working…") : "Ready");
+}
+
+function addMessage(role, text) {
+ if (text.ok) {
+ text = text.data;
+ }
+ const wrap = document.createElement("div");
+ wrap.className = `msg ${role}`;
+ wrap.innerHTML = `
+
${role}
+ `;
+ const bubble = wrap.querySelector(".bubble");
+ bubble.textContent = text;
+ bubble.onclick = function () {
+ if (bubble.scrollHeight > 100 && role === "tool") {
+ bubble.style.maxHeight = bubble.style.maxHeight == 'none' ? '' : 'none';
+ bubble.style.borderBottom = bubble.style.borderBottom == '' ? 'dotted 2px gray' : '';
+ }
+ }
+ msgsEl.appendChild(wrap);
+ msgsEl.scrollTop = msgsEl.scrollHeight;
+}
+
+function setStatus(text) {
+ statusEl.textContent = text;
+}
+
+const worker = new Worker("./ifc_worker.js", { type: "module" });
+
+function callWorker(type, payload = {}) {
+ return new Promise((resolve, reject) => {
+ const id = crypto.randomUUID();
+ const onMsg = (ev) => {
+ const msg = ev.data;
+ if (!msg || msg.id !== id) return;
+ worker.removeEventListener("message", onMsg);
+ if (msg.ok) resolve(msg);
+ else reject(new Error(msg.error || "Worker error"));
+ };
+ worker.addEventListener("message", onMsg);
+ worker.postMessage({ id, type, payload });
+ });
+}
+
+// ---- OpenAI Responses API tool schemas (should match ifcmcp.core openai_tools()) ----
+// Docs show Responses API function_call items + function_call_output loop. :contentReference[oaicite:4]{index=4}
+const tools = [
+ {
+ type: "function", name: "ifc_new", description: "Create a new empty IFC model in memory.",
+ parameters: { type: "object", properties: { schema: { type: "string" } }, required: [], additionalProperties: false }
+ },
+ {
+ type: "function", name: "ifc_summary", description: "Get a concise overview of the loaded IFC model.",
+ parameters: { type: "object", properties: {}, required: [], additionalProperties: false }
+ },
+ {
+ type: "function", name: "ifc_tree", description: "Get the full spatial hierarchy tree.",
+ parameters: { type: "object", properties: {}, required: [], additionalProperties: false }
+ },
+ {
+ type: "function", name: "ifc_select", description: "Select elements using ifcopenshell selector syntax (e.g. 'IfcWall').",
+ parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"], additionalProperties: false }
+ },
+ {
+ type: "function", name: "ifc_info", description: "Inspect an entity by STEP id.",
+ parameters: { type: "object", properties: { element_id: { type: "integer" } }, required: ["element_id"], additionalProperties: false }
+ },
+ {
+ type: "function", name: "ifc_relations", description: "Get relationships for an element. traverse='up' walks to IfcProject.",
+ parameters: {
+ type: "object", properties: { element_id: { type: "integer" }, traverse: { type: "string" } },
+ required: ["element_id"], additionalProperties: false
+ }
+ },
+ {
+ type: "function", name: "ifc_clash", description: "Run clash/clearance checks for an element.",
+ parameters: {
+ type: "object", properties: { element_id: { type: "integer" }, clearance: { type: "number" }, tolerance: { type: "number" }, scope: { type: "string" } },
+ required: ["element_id"], additionalProperties: false
+ }
+ },
+ {
+ type: "function", name: "ifc_list", description: "List ifcopenshell.api modules or functions within a module.",
+ parameters: { type: "object", properties: { module: { type: "string" } }, required: [], additionalProperties: false }
+ },
+ {
+ type: "function", name: "ifc_docs", description: "Get documentation for an ifcopenshell.api function, 'module.function'.",
+ parameters: { type: "object", properties: { function_path: { type: "string" } }, required: ["function_path"], additionalProperties: false }
+ },
+ {
+ type: "function", name: "ifc_edit", description: "Execute an ifcopenshell.api mutation; params is a JSON string of stringly-typed kwargs.",
+ parameters: { type: "object", properties: { function_path: { type: "string" }, params: { type: "string" } }, required: ["function_path"], additionalProperties: false }
+ },
+ {
+ type: "function", name: "ifc_validate", description: "Validate the loaded model. Returns valid bool and list of issues.",
+ parameters: { type: "object", properties: { express_rules: { type: "boolean" } }, required: [], additionalProperties: false }
+ },
+ {
+ type: "function", name: "ifc_schedule", description: "List work schedules and nested tasks. Use max_depth=1 for top-level phases only on large projects.",
+ parameters: { type: "object", properties: { max_depth: { type: "integer" } }, required: [], additionalProperties: false }
+ },
+ {
+ type: "function", name: "ifc_cost", description: "List cost schedules and nested cost items. Use max_depth=1 for top-level sections only on large BoQs.",
+ parameters: { type: "object", properties: { max_depth: { type: "integer" } }, required: [], additionalProperties: false }
+ },
+ {
+ type: "function", name: "ifc_schema", description: "Return IFC class documentation for an entity type.",
+ parameters: { type: "object", properties: { entity_type: { type: "string" } }, required: ["entity_type"], additionalProperties: false }
+ },
+ {
+ type: "function", name: "ifc_quantify", description: "Run quantity take-off (QTO) on the model. Modifies model in-place; call ifc_save() after.",
+ parameters: { type: "object", properties: { rule: { type: "string" }, selector: { type: "string" } }, required: ["rule"], additionalProperties: false }
+ },
+];
+
+const SYSTEM_INSTRUCTIONS = `
+You are an IFC copilot running in a browser. You can call tools to inspect or modify the currently loaded IFC model.
+Rules:
+- If the user asks about model contents (counts, lists, properties, hierarchy), use tools like ifc_summary/ifc_select/ifc_info/ifc_tree.
+- If the user asks to change the model, prefer: (1) ifc_list to find candidate API modules, (2) ifc_docs for the exact function signature, then (3) ifc_edit.
+- If there is no model and the user wants to create one, call ifc_new.
+- After edits, explain what changed and suggest downloading the IFC.
+Be concise. Avoid dumping huge trees unless asked.
+`;
+
+let inputItems = []; // running conversation state (Responses API style)
+
+async function openAIResponsesCreate({ apiKey, model, input, tools }) {
+ const res = await fetch("https://api.openai.com/v1/responses", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${apiKey}`,
+ },
+ body: JSON.stringify({
+ model,
+ instructions: SYSTEM_INSTRUCTIONS,
+ tools,
+ input,
+ }),
+ });
+
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(`OpenAI error ${res.status}: ${text}`);
+ }
+ return await res.json();
+}
+
+function extractAssistantText(response) {
+ const out = [];
+ for (const item of response.output ?? []) {
+ if (item.type === "message" && item.role === "assistant") {
+ for (const c of item.content ?? []) {
+ if (c.type === "output_text") out.push(c.text);
+ }
+ }
+ }
+ return out.join("\n").trim();
+}
+
+async function runAgentTurn(userText) {
+ const apiKey = apiKeyEl.value.trim();
+ if (!apiKey) throw new Error("Missing API key");
+
+ // Add user message
+ inputItems.push({ role: "user", content: userText });
+
+ // Tool-calling loop (Responses API): append response.output, execute function_call items, append function_call_output.
+ for (let i = 0; i < 64; i++) {
+ const response = await openAIResponsesCreate({
+ apiKey,
+ model: modelEl.value,
+ input: inputItems,
+ tools,
+ });
+
+ // Keep ALL output items (incl reasoning/tool calls) in the running state.
+ inputItems.push(...(response.output ?? []));
+
+ // Show any assistant text immediately
+ const text = extractAssistantText(response);
+ if (text) addMessage("assistant", text);
+
+ const calls = (response.output ?? []).filter((x) => x.type === "function_call");
+ if (calls.length === 0) return;
+
+ for (const call of calls) {
+ let args = {};
+ try { args = call.arguments ? JSON.parse(call.arguments) : {}; }
+ catch { args = {}; }
+
+ addMessage("tool", `→ ${call.name}(${JSON.stringify(args)})`);
+
+ const toolRes = await callWorker("toolCall", { name: call.name, args });
+
+ // Feed tool result back to the model
+ inputItems.push({
+ type: "function_call_output",
+ call_id: call.call_id,
+ output: JSON.stringify(toolRes.result),
+ });
+
+ addMessage("tool", `← ${call.name}: ${JSON.stringify(toolRes.result, null, 2)}`);
+ }
+ }
+
+ addMessage("assistant", "I hit the tool-call loop limit. Try narrowing your request.");
+}
+
+sendBtn.onclick = async () => {
+ const text = inputEl.value.trim();
+ if (!text) return;
+ inputEl.value = "";
+ addMessage("user", text);
+ try {
+ setBusy(true, "Thinking…");
+ await runAgentTurn(text);
+ setBusy(false, "Ready");
+ } catch (e) {
+ setBusy(true, "Error");
+ addMessage("assistant", `Error: ${e.message}`);
+ }
+};
+
+inputEl.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ sendBtn.click();
+ }
+});
+
+ifcFileEl.onchange = async () => {
+ const f = ifcFileEl.files?.[0];
+ if (!f) return;
+ setBusy(true, "Loading IFC into Pyodide…");
+ const buf = await f.arrayBuffer();
+ try {
+ const r = await callWorker("loadIfc", { filename: f.name, bytes: buf }, [buf]);
+ addMessage("assistant", r.result);
+ setBusy(false, "Ready");
+ } catch (e) {
+ setStatus(true, "Error");
+ addMessage("assistant", `Load error: ${e.message}`);
+ }
+};
+
+newBtn.onclick = async () => {
+ try {
+ setBusy(true, "Creating new model…");
+ const r = await callWorker("toolCall", { name: "ifc_new", args: { schema: "IFC4" } });
+ addMessage("assistant", `New model: ${JSON.stringify(r.result)}`);
+ setBusy(false, "Ready");
+ } catch (e) {
+ setBusy(true, "Error");
+ addMessage("assistant", `Error: ${e.message}`);
+ }
+};
+
+downloadBtn.onclick = async () => {
+ try {
+ setBusy(true, "Exporting IFC…");
+ const r = await callWorker("exportIfc", {});
+ const blob = new Blob([r.bytes], { type: "application/octet-stream" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = r.filename || "model.ifc";
+ a.click();
+ URL.revokeObjectURL(url);
+ setBusy(false, "Ready");
+ } catch (e) {
+ setBusy(true, "Error");
+ addMessage("assistant", `Export error: ${e.message}`);
+ }
+};
+
+(async () => {
+ try {
+ setBusy(true, "Initializing Pyodide and IfcOpenShell for in-memory IFC access…");
+ await callWorker("init", {});
+ setBusy(false, "Ready");
+ } catch (e) {
+ setBusy(true, "Error");
+ addMessage("assistant", `Worker init failed: ${e.message}`);
+ }
+})();
\ No newline at end of file
diff --git a/src/ifcchat/ifc_worker.js b/src/ifcchat/ifc_worker.js
new file mode 100644
index 0000000000..517bc4b24c
--- /dev/null
+++ b/src/ifcchat/ifc_worker.js
@@ -0,0 +1,110 @@
+// ifc_worker.js (MODULE WORKER)
+import { loadPyodide } from "https://cdn.jsdelivr.net/pyodide/v0.29.3/full/pyodide.mjs";
+
+let pyodide = null;
+let callToolPy = null;
+let initPromise = null;
+
+function ok(id, extra = {}, transfer = []) {
+ self.postMessage({ id, ok: true, ...extra }, transfer);
+}
+function fail(id, error) {
+ self.postMessage({ id, ok: false, error: String(error?.message || error) });
+}
+
+async function ensurePyodide() {
+ if (initPromise) return initPromise;
+
+ initPromise = (async () => {
+ // Passing indexURL avoids some environments failing to infer it from the module URL. :contentReference[oaicite:3]{index=3}
+ pyodide = await loadPyodide({
+ indexURL: "https://cdn.jsdelivr.net/pyodide/v0.29.3/full/",
+ });
+
+ await pyodide.loadPackage("micropip");
+ await pyodide.loadPackage("numpy");
+ await pyodide.loadPackage("shapely");
+ await pyodide.loadPackage("typing-extensions");
+
+ const micropip = pyodide.pyimport("micropip");
+ micropip.install("python-dateutil")
+
+ // Detect python minor version (3.12 vs 3.13) and pick a matching wheel.
+ const pyVer = pyodide.runPython(`
+import sys
+f"{sys.version_info.major}.{sys.version_info.minor}"
+ `);
+
+ const wheelUrl =
+ pyVer === "3.13"
+ ? "https://ifcopenshell.github.io/wasm-wheels/ifcopenshell-0.8.3+34a1bc6-cp313-cp313-emscripten_4_0_9_wasm32.whl"
+ : "https://ifcopenshell.github.io/wasm-wheels/ifcopenshell-0.8.2+d50e806-cp312-cp312-emscripten_3_1_58_wasm32.whl";
+
+ await micropip.install(wheelUrl);
+
+ await micropip.install([
+ "./dist/ifcquery-0.8.5-py3-none-any.whl",
+ "./dist/ifcedit-0.8.5-py3-none-any.whl",
+ "./dist/ifcopenshell_mcp-0.8.5-py3-none-any.whl",
+ "./dist/lark-1.3.1-py3-none-any.whl",
+ "./dist/isodate-0.7.2-py3-none-any.whl",
+ ])
+ await pyodide.runPythonAsync(`
+from ifcmcp.embedded import call_tool as _call_tool
+ `);
+ callToolPy = pyodide.globals.get("_call_tool");
+ })();
+
+ return initPromise;
+}
+
+function callTool(name, args) {
+ const pyArgs = pyodide.toPy(args);
+ const res = callToolPy(name, pyArgs);
+ pyArgs.destroy();
+ const resJs = res.toJs({ dict_converter: Object.fromEntries });
+ res.destroy();
+ return resJs;
+}
+
+self.onmessage = async (ev) => {
+ const { id, type, payload } = ev.data || {};
+ try {
+ if (type === "init") {
+ await ensurePyodide();
+ ok(id, { result: "ok" });
+ return;
+ }
+
+ await ensurePyodide();
+
+ if (type === "loadIfc") {
+ const { filename, bytes } = payload;
+ const path = `/tmp/${filename || "model.ifc"}`;
+ pyodide.FS.mkdirTree("/tmp");
+ pyodide.FS.writeFile(path, new Uint8Array(bytes));
+ const result = callTool("ifc_load", { path });
+ ok(id, { result });
+ return;
+ }
+
+ if (type === "exportIfc") {
+ const path = "/tmp/export.ifc";
+ const result = callTool("ifc_save", { path });
+ const data = pyodide.FS.readFile(path);
+ ok(id, { result, filename: "export.ifc", bytes: data }, [data.buffer]);
+ return;
+ }
+
+ if (type === "toolCall") {
+ const { name, args } = payload;
+ const result = callTool(name, args || {});
+ ok(id, { result });
+ return;
+ }
+
+ throw new Error(`Unknown message type: ${type}`);
+ } catch (e) {
+ fail(id, e);
+ }
+};
\ No newline at end of file
diff --git a/src/ifcchat/index.html b/src/ifcchat/index.html
new file mode 100644
index 0000000000..ac878ae615
--- /dev/null
+++ b/src/ifcchat/index.html
@@ -0,0 +1,322 @@
+
+
+
+
+
+
+ IfcOpenShell AI Assistant
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ • Upload an IFC, then ask “Summarize the model” or “List all IfcWalls”.
+ • Try “Add a new site and building named X” (will use ifc_edit).
+
+
+
+
+
+
+ Chat with your IFC model. This app uses ifcopenshell-mcp to interact with an IFC model. IFC is in its raw-serialized form provides little textual context on the meaning of attributes, the intent behind element instances and the resulting geometrical form. By using the IfcOpenShell high-level API through an MCP-like interface (Model Context Protocol; allows for structured interaction with APIs and tools) these limitations are addressed and the model can be interrogated on a higher semantic abstraction level. You can also use ifcopenshell-mcp on your local machine with any LLM provider that supports the MCP protocol.
+
+
+
+
+
+
+
+
+
+
+ IfcOpenShell AI Assistant
+
+
+
+
+ Status: Booting…
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/ifcclash/pyproject.toml b/src/ifcclash/pyproject.toml
index dc27c49efc..3b510d4ea5 100644
--- a/src/ifcclash/pyproject.toml
+++ b/src/ifcclash/pyproject.toml
@@ -19,6 +19,11 @@ dependencies = [
"ifcopenshell",
]
+[project.optional-dependencies]
+advanced = [
+ "scikit-learn",
+]
+
[project.urls]
Homepage = "http://ifcopenshell.org"
Documentation = "https://docs.ifcopenshell.org"
diff --git a/src/ifcedit/Makefile b/src/ifcedit/Makefile
new file mode 100644
index 0000000000..114e5f9ddc
--- /dev/null
+++ b/src/ifcedit/Makefile
@@ -0,0 +1,10 @@
+PACKAGE_NAME:=ifcedit
+include ../common.mk
+
+.PHONY: test
+test:
+ pytest tests
+
+.PHONY: qa
+qa:
+ black .
diff --git a/src/ifcedit/README.md b/src/ifcedit/README.md
new file mode 100644
index 0000000000..19b1ec8e7a
--- /dev/null
+++ b/src/ifcedit/README.md
@@ -0,0 +1,305 @@
+
+# ifcedit
+
+A CLI wrapper that exposes all 350+ `ifcopenshell.api` mutation functions as
+shell commands. Functions are auto-discovered at runtime via introspection --
+no hardcoded list to maintain.
+
+## Installation
+
+```bash
+pip install ifcedit
+```
+
+Requires `ifcopenshell`.
+
+## Usage
+
+```
+ifcedit [options] [--format json|text]
+```
+
+Three subcommands: `list` to discover functions, `docs` to read their
+documentation, and `run` to execute them.
+
+## Subcommands
+
+### list
+
+Discover available API modules and their functions.
+
+**List all modules:**
+
+```bash
+ifcedit list
+```
+
+```json
+[
+ {
+ "module": "root",
+ "description": "Functions for creating project-level entities",
+ "functions": ["create_entity", "remove_product", "copy_class"],
+ "count": 3
+ },
+ {
+ "module": "spatial",
+ "description": "Functions for managing spatial relationships",
+ "functions": ["assign_container", "unassign_container"],
+ "count": 2
+ }
+]
+```
+
+**List functions in a module:**
+
+```bash
+ifcedit list root
+```
+
+```json
+[
+ {
+ "name": "create_entity",
+ "description": "Create an IFC entity with optional initial attributes",
+ "params": [
+ {"name": "ifc_class", "type": "str", "required": true},
+ {"name": "name", "type": "Optional[str]"}
+ ]
+ }
+]
+```
+
+### docs
+
+Show full documentation for a specific function, including parameter
+descriptions from docstrings and return type.
+
+```bash
+ifcedit docs root.create_entity
+```
+
+```json
+{
+ "module": "root",
+ "function": "create_entity",
+ "description": "Create an IFC entity with optional initial attributes",
+ "long_description": "This function creates a new entity instance...",
+ "params": [
+ {
+ "name": "ifc_class",
+ "type": "str",
+ "required": true,
+ "description": "The IFC class name (e.g. 'IfcWall', 'IfcProject')"
+ },
+ {
+ "name": "name",
+ "type": "Optional[str]",
+ "description": "Optional name attribute"
+ }
+ ],
+ "return_type": "ifcopenshell.entity_instance",
+ "return_description": "The newly created entity instance"
+}
+```
+
+### run
+
+Execute an API function against an IFC file. Parameters are passed as
+`--key value` pairs after the function name.
+
+```bash
+ifcedit run model.ifc root.create_entity --ifc_class IfcWall --name "My Wall"
+```
+
+```json
+{
+ "ok": true,
+ "result": {"id": 42, "type": "IfcWall", "name": "My Wall"}
+}
+```
+
+**Options:**
+
+- `-o, --output ` -- write to a different file instead of overwriting the input
+- `--dry-run` -- validate parameters without executing or saving
+
+```bash
+# Save to a new file
+ifcedit run model.ifc root.create_entity -o out.ifc --ifc_class IfcWall
+
+# Validate without executing
+ifcedit run model.ifc root.create_entity --dry-run --ifc_class IfcWall
+```
+
+Dry-run output shows the resolved parameters:
+
+```json
+{
+ "ok": true,
+ "dry_run": true,
+ "module": "root",
+ "function": "create_entity",
+ "args": {"ifc_class": "IfcWall", "name": "My Wall"}
+}
+```
+
+## Parameter type coercion
+
+CLI strings are automatically converted to the types expected by each API
+function, using the function's type annotations:
+
+| Type | CLI input | Python value |
+|------|-----------|--------------|
+| `str` | `"hello"` | `"hello"` |
+| `int` | `"42"` or `"#42"` | `42` |
+| `float` | `"3.14"` | `3.14` |
+| `bool` | `"true"`, `"1"`, `"yes"` | `True` |
+| `Optional[X]` | `"none"` | `None` |
+| `entity_instance` | `"42"` or `"#42"` | resolved from model by step ID |
+| `list[entity_instance]` | `"5,6,7"` or `"[5, 6, 7]"` | list of resolved entities |
+| `dict` | `'{"key": "val"}'` | parsed JSON object |
+| `Literal["A", "B"]` | `"A"` | validated against allowed values |
+
+## Examples
+
+```bash
+# Create a project
+ifcedit run model.ifc root.create_entity --ifc_class IfcProject --name "My Project"
+
+# Assign an element to a storey
+ifcedit run model.ifc spatial.assign_container --products 10 --relating_structure 4
+
+# Assign multiple elements at once
+ifcedit run model.ifc aggregate.assign_object --products "5,6,7" --relating_object 1
+
+# Add a property set
+ifcedit run model.ifc pset.add_pset --product 10 --name "Pset_WallCommon"
+
+# Edit properties
+ifcedit run model.ifc pset.edit_pset --pset 15 \
+ --properties '{"IsExternal": true, "FireRating": "2HR"}'
+```
+
+### foreach
+
+Apply an API function to each element in a JSON array read from stdin.
+`{field}` placeholders in argument values are substituted with fields from
+each JSON object. The model is opened once and saved once regardless of how
+many elements are processed.
+
+```bash
+ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id}
+```
+
+```json
+{"ok": true, "count": 36, "errors": []}
+```
+
+Placeholder tokens match the fields emitted by `ifcquery` — typically `{id}`,
+`{type}`, and `{name}`:
+
+```bash
+ifcquery model.ifc select 'IfcDoor' | ifcedit foreach model.ifc attribute.edit_attributes \
+ --product {id} --attributes '{"Name": "Door"}'
+```
+
+**Options:**
+
+- `-o, --output ` -- write to a different file instead of overwriting the input
+
+**Output:**
+
+- `count` -- number of elements successfully processed
+- `errors` -- list of per-element failures, each with `index`, `item`, and `error`; processing continues past errors
+
+```json
+{
+ "ok": false,
+ "count": 34,
+ "errors": [
+ {"index": 2, "item": {"id": 55, "type": "IfcWindow", "name": "W03"}, "error": "Entity #55 not found in model"}
+ ]
+}
+```
+
+Exit code is 1 if any element failed.
+
+### quantify
+
+Run quantity take-off (QTO) on an IFC file, computing physical measurements
+(volume, area, length, count, weight) and writing them back as
+`IfcElementQuantity` property sets. Uses `ifc5d` rules.
+
+**List available rules:**
+
+```bash
+ifcedit quantify list
+```
+
+```json
+[
+ {"name": "IFC4QtoBaseQuantities"},
+ {"name": "IFC4X3QtoBaseQuantities"}
+]
+```
+
+**Run QTO on a file:**
+
+```bash
+ifcedit quantify run model.ifc IFC4QtoBaseQuantities
+ifcedit quantify run model.ifc IFC4QtoBaseQuantities --selector IfcWall
+ifcedit quantify run model.ifc IFC4QtoBaseQuantities -o model_qto.ifc
+```
+
+```json
+{"ok": true, "rule": "IFC4QtoBaseQuantities", "elements_quantified": 42}
+```
+
+Options:
+
+- `--selector ` -- ifcopenshell selector to restrict elements (default: all `IfcElement`)
+- `-o, --output ` -- write to a different file instead of overwriting the input
+
+Note: `quantify run` writes geometry-based measurements and requires the
+IfcOpenShell C++ geometry bindings for elements with computed quantities.
+
+## Error handling
+
+Errors are reported in the JSON response:
+
+```json
+{
+ "ok": false,
+ "error": "Entity #999 not found in model"
+}
+```
+
+Exit code is 0 on success, 1 on error.
+
+## Relationship to ifcquery
+
+`ifcedit` and `ifcquery` are complementary tools:
+
+- **ifcquery** reads and inspects IFC models (summary, tree, info, select, relations, clash, validate, schedule, cost, schema, contexts, materials, plot, render)
+- **ifcedit** modifies IFC models by wrapping `ifcopenshell.api` functions, and runs QTO via `quantify`
+
+A typical workflow: inspect with `ifcquery`, look up the right API function
+with `ifcedit docs`, then apply changes with `ifcedit run`.
+
+The two tools also compose directly in shell scripts. Use `ifcquery --format ids`
+to feed a list of IDs into a `run` parameter, or pipe `ifcquery select` JSON
+into `ifcedit foreach` to apply an operation to every matching element:
+
+```bash
+# Aggregate — pass all IDs as a list parameter
+ifcedit run model.ifc spatial.unassign_container \
+ --products "$(ifcquery model.ifc --format ids select 'IfcWall')"
+
+# Fan-out — one operation per element, model opened and saved once
+ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id}
+```
+
+## License
+
+LGPLv3+ -- see the IfcOpenShell project license.
diff --git a/src/ifcedit/ifcedit/__init__.py b/src/ifcedit/ifcedit/__init__.py
new file mode 100644
index 0000000000..eac2eac799
--- /dev/null
+++ b/src/ifcedit/ifcedit/__init__.py
@@ -0,0 +1,20 @@
+# This file was generated with the assistance of an AI coding tool.
+# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
+# Copyright (C) 2026 Bruno Postle
+#
+# This file is part of IfcEdit.
+#
+# IfcEdit is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# IfcEdit is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with IfcEdit. If not, see .
+
+__version__ = version = "0.0.0"
diff --git a/src/ifcedit/ifcedit/__main__.py b/src/ifcedit/ifcedit/__main__.py
new file mode 100644
index 0000000000..28292f378e
--- /dev/null
+++ b/src/ifcedit/ifcedit/__main__.py
@@ -0,0 +1,265 @@
+# This file was generated with the assistance of an AI coding tool.
+# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
+# Copyright (C) 2026 Bruno Postle
+#
+# This file is part of IfcEdit.
+#
+# IfcEdit is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# IfcEdit is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with IfcEdit. If not, see .
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+
+import ifcopenshell
+
+from ifcedit.discover import function_docs, list_functions, list_modules
+from ifcedit.foreach import run_foreach
+from ifcedit.quantify import list_rules, run_quantify
+from ifcedit.run import run_api
+
+
+def format_output(data, fmt: str) -> str:
+ if fmt == "json":
+ return json.dumps(data, indent=2, ensure_ascii=False)
+ elif fmt == "text":
+ return _format_text(data)
+ return json.dumps(data, indent=2, ensure_ascii=False)
+
+
+def _format_text(data, indent: int = 0) -> str:
+ prefix = " " * indent
+ lines = []
+ if isinstance(data, dict):
+ for key, value in data.items():
+ if isinstance(value, (dict, list)):
+ lines.append(f"{prefix}{key}:")
+ lines.append(_format_text(value, indent + 1))
+ else:
+ lines.append(f"{prefix}{key}: {value}")
+ elif isinstance(data, list):
+ for item in data:
+ if isinstance(item, dict):
+ lines.append(_format_text(item, indent))
+ lines.append("")
+ else:
+ lines.append(f"{prefix}- {item}")
+ else:
+ lines.append(f"{prefix}{data}")
+ return "\n".join(lines)
+
+
+def cmd_list(args):
+ if args.module:
+ try:
+ functions = list_functions(args.module)
+ except Exception as e:
+ print(f"Error: {e}", file=sys.stderr)
+ sys.exit(1)
+ print(format_output(functions, args.output_format))
+ else:
+ modules = list_modules()
+ print(format_output(modules, args.output_format))
+
+
+def cmd_docs(args):
+ parts = args.function_path.split(".")
+ if len(parts) != 2:
+ print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr)
+ sys.exit(1)
+ module, function = parts
+ try:
+ docs = function_docs(module, function)
+ except Exception as e:
+ print(f"Error: {e}", file=sys.stderr)
+ sys.exit(1)
+ print(format_output(docs, args.output_format))
+
+
+def cmd_run(args, extra_args):
+ try:
+ model = ifcopenshell.open(args.ifc_file)
+ except Exception as e:
+ print(f"Error: Could not open IFC file: {e}", file=sys.stderr)
+ sys.exit(1)
+
+ parts = args.function_path.split(".")
+ if len(parts) != 2:
+ print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr)
+ sys.exit(1)
+ module, function = parts
+
+ # Parse extra --key value arguments into a dict
+ raw_kwargs = _parse_extra_args(extra_args)
+
+ if args.dry_run:
+ result = {"ok": True, "dry_run": True, "module": module, "function": function, "args": raw_kwargs}
+ else:
+ result = run_api(model, module, function, raw_kwargs)
+
+ if result["ok"]:
+ output_path = args.output or args.ifc_file
+ model.write(output_path)
+
+ print(format_output(result, args.output_format))
+ if not result["ok"]:
+ sys.exit(1)
+
+
+def _parse_extra_args(extra: list[str]) -> dict[str, str]:
+ """Parse a list of ['--key', 'value', ...] into a dict."""
+ kwargs = {}
+ i = 0
+ while i < len(extra):
+ arg = extra[i]
+ if arg.startswith("--"):
+ key = arg[2:]
+ if i + 1 < len(extra) and not extra[i + 1].startswith("--"):
+ kwargs[key] = extra[i + 1]
+ i += 2
+ else:
+ # Flag without value — treat as "true"
+ kwargs[key] = "true"
+ i += 1
+ else:
+ print(f"Error: Unexpected argument: {arg}", file=sys.stderr)
+ sys.exit(1)
+ return kwargs
+
+
+def cmd_foreach(args, extra_args):
+ try:
+ model = ifcopenshell.open(args.ifc_file)
+ except Exception as e:
+ print(f"Error: Could not open IFC file: {e}", file=sys.stderr)
+ sys.exit(1)
+
+ parts = args.function_path.split(".")
+ if len(parts) != 2:
+ print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr)
+ sys.exit(1)
+ module, function = parts
+
+ raw_kwargs_template = _parse_extra_args(extra_args)
+
+ try:
+ stdin_data = json.load(sys.stdin)
+ except json.JSONDecodeError as e:
+ print(f"Error: Could not parse JSON from stdin: {e}", file=sys.stderr)
+ sys.exit(1)
+
+ if not isinstance(stdin_data, list):
+ print("Error: stdin must be a JSON array", file=sys.stderr)
+ sys.exit(1)
+
+ result = run_foreach(model, module, function, raw_kwargs_template, stdin_data)
+
+ if result["ok"]:
+ output_path = args.output or args.ifc_file
+ model.write(output_path)
+
+ print(format_output(result, args.output_format))
+ if not result["ok"]:
+ sys.exit(1)
+
+
+def cmd_quantify(args, extra_args):
+ if args.quantify_command == "list":
+ result = list_rules()
+ print(format_output(result, args.output_format))
+ elif args.quantify_command == "run":
+ try:
+ model = ifcopenshell.open(args.ifc_file)
+ except Exception as e:
+ print(f"Error: Could not open IFC file: {e}", file=sys.stderr)
+ sys.exit(1)
+ selector = args.selector or None
+ result = run_quantify(model, args.rule_name, selector=selector)
+ if result["ok"]:
+ output_path = args.output or args.ifc_file
+ model.write(output_path)
+ print(format_output(result, args.output_format))
+ if not result["ok"]:
+ sys.exit(1)
+ else:
+ print("Error: quantify requires a subcommand: list or run", file=sys.stderr)
+ sys.exit(1)
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ prog="ifcedit",
+ description="CLI wrapper for ifcopenshell.api IFC model mutation functions",
+ )
+ parser.add_argument(
+ "--format",
+ choices=["json", "text"],
+ default="json",
+ dest="output_format",
+ help="Output format (default: json)",
+ )
+
+ subparsers = parser.add_subparsers(dest="command", required=True)
+
+ # list
+ list_parser = subparsers.add_parser("list", help="List API modules or functions in a module")
+ list_parser.add_argument("module", nargs="?", help="Module name (omit to list all modules)")
+
+ # docs
+ docs_parser = subparsers.add_parser("docs", help="Show full documentation for an API function")
+ docs_parser.add_argument("function_path", help="module.function (e.g. root.create_entity)")
+
+ # run
+ run_parser = subparsers.add_parser("run", help="Execute an API function on an IFC file")
+ run_parser.add_argument("ifc_file", help="Path to the IFC file")
+ run_parser.add_argument("function_path", help="module.function (e.g. root.create_entity)")
+ run_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)")
+ run_parser.add_argument("--dry-run", action="store_true", help="Validate without executing or saving")
+
+ # foreach
+ foreach_parser = subparsers.add_parser(
+ "foreach",
+ help="Apply an API function to each element in a JSON array read from stdin",
+ )
+ foreach_parser.add_argument("ifc_file", help="Path to the IFC file")
+ foreach_parser.add_argument("function_path", help="module.function (e.g. attribute.edit_attributes)")
+ foreach_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)")
+
+ # quantify
+ quantify_parser = subparsers.add_parser("quantify", help="Quantity take-off (QTO) using ifc5d rules")
+ quantify_sub = quantify_parser.add_subparsers(dest="quantify_command")
+ quantify_sub.add_parser("list", help="List available QTO rule names")
+ qrun_parser = quantify_sub.add_parser("run", help="Run QTO on an IFC file")
+ qrun_parser.add_argument("ifc_file", help="Path to the IFC file")
+ qrun_parser.add_argument("rule_name", help="QTO rule name (e.g. IFC4QtoBaseQuantities)")
+ qrun_parser.add_argument("--selector", help="ifcopenshell selector to restrict elements (default: all IfcElement)")
+ qrun_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)")
+
+ args, extra = parser.parse_known_args()
+
+ if args.command == "list":
+ cmd_list(args)
+ elif args.command == "docs":
+ cmd_docs(args)
+ elif args.command == "run":
+ cmd_run(args, extra)
+ elif args.command == "foreach":
+ cmd_foreach(args, extra)
+ elif args.command == "quantify":
+ cmd_quantify(args, extra)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ifcedit/ifcedit/coerce.py b/src/ifcedit/ifcedit/coerce.py
new file mode 100644
index 0000000000..5a410ec186
--- /dev/null
+++ b/src/ifcedit/ifcedit/coerce.py
@@ -0,0 +1,180 @@
+# This file was generated with the assistance of an AI coding tool.
+# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
+# Copyright (C) 2026 Bruno Postle
+#
+# This file is part of IfcEdit.
+#
+# IfcEdit is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# IfcEdit is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with IfcEdit. If not, see .
+
+from __future__ import annotations
+
+import json
+import typing
+
+import ifcopenshell
+
+
+def coerce_value(
+ value_str: str,
+ type_hint,
+ model: ifcopenshell.file | None = None,
+ lookup_file: ifcopenshell.file | None = None,
+):
+ """Convert a CLI string argument to the proper Python type based on a type hint.
+
+ Args:
+ value_str: The raw string from the CLI.
+ type_hint: The type annotation from the function signature.
+ model: The main open IFC model, needed to resolve entity instance references by ID.
+ lookup_file: Override file for entity resolution (e.g. a library file for
+ project.append_asset). When provided, entity IDs are looked up here instead
+ of in model.
+
+ Returns:
+ The converted Python value.
+
+ Raises:
+ ValueError: If the value cannot be converted.
+ TypeError: If the type hint is not supported.
+ """
+ # When a library file has been opened, entity IDs are resolved from it, not the main model.
+ effective_lookup = lookup_file if lookup_file is not None else model
+
+ if type_hint is None:
+ return value_str
+
+ origin = typing.get_origin(type_hint)
+ args = typing.get_args(type_hint)
+
+ # Union / Optional
+ if origin is typing.Union:
+ non_none_types = [a for a in args if a is not type(None)]
+ if value_str.lower() == "none":
+ if type(None) in args:
+ return None
+ # Try each non-None type in order
+ for t in non_none_types:
+ try:
+ return coerce_value(value_str, t, model, lookup_file)
+ except (ValueError, TypeError):
+ continue
+ raise ValueError(f"Cannot convert '{value_str}' to any of {non_none_types}")
+
+ # Literal
+ if origin is typing.Literal:
+ allowed = args
+ if value_str in [str(a) for a in allowed]:
+ # return the actual literal value with proper type
+ for a in allowed:
+ if str(a) == value_str:
+ return a
+ raise ValueError(f"'{value_str}' is not one of: {', '.join(repr(a) for a in allowed)}")
+
+ # list types
+ if origin is list:
+ if args and _is_entity_type(args[0]):
+ return _coerce_entity_list(value_str, effective_lookup)
+ if args:
+ items = _split_list(value_str)
+ return [coerce_value(item.strip(), args[0], model, lookup_file) for item in items]
+ return _split_list(value_str)
+
+ # dict types
+ if origin is dict:
+ return _floatify_numeric_lists(json.loads(value_str))
+
+ # Simple types
+ if type_hint is str:
+ return value_str
+ if type_hint is int:
+ return int(value_str.lstrip("#"))
+ if type_hint is float:
+ return float(value_str)
+ if type_hint is bool:
+ return value_str.lower() in ("true", "1", "yes")
+
+ # ifcopenshell.file — open from path string
+ if type_hint is ifcopenshell.file:
+ return ifcopenshell.open(value_str)
+
+ # entity_instance
+ if _is_entity_type(type_hint):
+ return _coerce_entity(value_str, effective_lookup)
+
+ # Fallback: try json.loads for complex types, then plain string
+ try:
+ return json.loads(value_str)
+ except (json.JSONDecodeError, TypeError):
+ return value_str
+
+
+def _is_entity_type(hint) -> bool:
+ """Check if a type hint refers to ifcopenshell.entity_instance."""
+ if hint is ifcopenshell.entity_instance:
+ return True
+ if isinstance(hint, type) and issubclass(hint, ifcopenshell.entity_instance):
+ return True
+ return False
+
+
+def _coerce_entity(value_str: str | int, lookup_file: ifcopenshell.file | None) -> ifcopenshell.entity_instance:
+ """Resolve a step ID string like '123' or '#123' to an entity instance."""
+ if lookup_file is None:
+ raise ValueError("Cannot resolve entity reference without an IFC model")
+ if isinstance(value_str, int):
+ entity_id = value_str
+ else:
+ entity_id = int(value_str.strip().lstrip("#"))
+ try:
+ return lookup_file.by_id(entity_id)
+ except RuntimeError:
+ raise ValueError(f"Entity #{entity_id} not found in model")
+
+
+def _coerce_entity_list(value_str: str, lookup_file: ifcopenshell.file | None) -> list[ifcopenshell.entity_instance]:
+ """Resolve a comma-separated list of step IDs to entity instances."""
+ items = _split_list(value_str)
+ return [_coerce_entity(item.strip(), lookup_file) for item in items]
+
+
+def _floatify_numeric_lists(obj):
+ """Recursively convert lists of numbers to lists of floats.
+
+ IFC C++ bindings require Python floats (not ints) for AGGREGATE OF DOUBLE
+ attributes (e.g. DirectionRatios, Coordinates). JSON parsing produces ints
+ for whole numbers like 0, which causes a TypeError at the binding level.
+ """
+ if isinstance(obj, dict):
+ return {k: _floatify_numeric_lists(v) for k, v in obj.items()}
+ if (
+ isinstance(obj, list)
+ and obj
+ and all(isinstance(v, (int, float)) for v in obj)
+ and any(isinstance(v, float) for v in obj)
+ ):
+ return [float(v) for v in obj]
+ return obj
+
+
+def _split_list(value_str: str) -> list[str]:
+ """Split a comma-separated string, handling JSON arrays too."""
+ value_str = value_str.strip()
+ if value_str.startswith("["):
+ try:
+ parsed = json.loads(value_str)
+ if isinstance(parsed, list):
+ return [json.dumps(item) if isinstance(item, (dict, list)) else str(item) for item in parsed]
+ except json.JSONDecodeError:
+ pass
+ return [item.strip() for item in value_str.split(",") if item.strip()]
diff --git a/src/ifcedit/ifcedit/discover.py b/src/ifcedit/ifcedit/discover.py
new file mode 100644
index 0000000000..b26a93f6c6
--- /dev/null
+++ b/src/ifcedit/ifcedit/discover.py
@@ -0,0 +1,282 @@
+# This file was generated with the assistance of an AI coding tool.
+# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
+# Copyright (C) 2026 Bruno Postle
+#
+# This file is part of IfcEdit.
+#
+# IfcEdit is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# IfcEdit is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with IfcEdit. If not, see .
+
+from __future__ import annotations
+
+import importlib
+import inspect
+import re
+import typing
+from pathlib import Path
+
+
+def _api_package_path() -> Path:
+ """Return the filesystem path to the ifcopenshell.api package."""
+ import ifcopenshell.api
+
+ return Path(ifcopenshell.api.__file__).parent
+
+
+def list_modules() -> list[dict]:
+ """List all API modules with their function counts and descriptions.
+
+ Returns a list of dicts: [{"module": "root", "description": "...", "functions": [...], "count": 4}, ...]
+ """
+ api_path = _api_package_path()
+ modules = []
+ for child in sorted(api_path.iterdir()):
+ if not child.is_dir() or child.name.startswith("_"):
+ continue
+ init_file = child / "__init__.py"
+ if not init_file.exists():
+ continue
+ try:
+ mod = importlib.import_module(f"ifcopenshell.api.{child.name}")
+ except Exception:
+ continue
+ all_names = getattr(mod, "__all__", [])
+ if not all_names:
+ continue
+ description = ""
+ if mod.__doc__:
+ description = mod.__doc__.strip().split("\n")[0]
+ modules.append(
+ {
+ "module": child.name,
+ "description": description,
+ "functions": list(all_names),
+ "count": len(all_names),
+ }
+ )
+ return modules
+
+
+def list_functions(module: str) -> list[dict]:
+ """List functions in an API module with one-line descriptions and parameter info.
+
+ Returns a list of dicts: [{"name": "create_entity", "description": "...", "params": [...]}]
+ """
+ mod = importlib.import_module(f"ifcopenshell.api.{module}")
+ all_names = getattr(mod, "__all__", [])
+ functions = []
+ for name in all_names:
+ fn = _get_underlying_function(module, name)
+ if fn is None:
+ continue
+ description = ""
+ if fn.__doc__:
+ description = fn.__doc__.strip().split("\n")[0]
+ params = _extract_params(fn)
+ functions.append(
+ {
+ "name": name,
+ "description": description,
+ "params": params,
+ }
+ )
+ return functions
+
+
+def function_docs(module: str, function: str) -> dict:
+ """Full documentation for a single API function.
+
+ Returns a dict with: module, function, description, params (with types/defaults/descriptions), return_type
+ """
+ fn = _get_underlying_function(module, function)
+ if fn is None:
+ raise ValueError(f"Function '{module}.{function}' not found")
+
+ description = ""
+ long_description = ""
+ if fn.__doc__:
+ description, long_description = _parse_docstring_body(fn.__doc__)
+
+ params = _extract_params(fn)
+ param_descriptions = _parse_param_docs(fn.__doc__ or "")
+ for param in params:
+ if param["name"] in param_descriptions:
+ param["description"] = param_descriptions[param["name"]]
+
+ return_type = _format_type_hint(typing.get_type_hints(fn).get("return"))
+ return_description = _parse_return_doc(fn.__doc__ or "")
+
+ result = {
+ "module": module,
+ "function": function,
+ "description": description,
+ "long_description": long_description,
+ "params": params,
+ }
+ if return_type:
+ result["return_type"] = return_type
+ if return_description:
+ result["return_description"] = return_description
+ return result
+
+
+def _get_underlying_function(module: str, function: str):
+ """Get the actual function object (unwrapping the listener wrapper if needed)."""
+ try:
+ fn_module = importlib.import_module(f"ifcopenshell.api.{module}.{function}")
+ fn = getattr(fn_module, function, None)
+ return fn
+ except (ImportError, AttributeError):
+ return None
+
+
+def _extract_params(fn) -> list[dict]:
+ """Extract parameter info from a function's signature and type hints."""
+ sig = inspect.signature(fn)
+ try:
+ hints = typing.get_type_hints(fn)
+ except Exception:
+ hints = {}
+
+ params = []
+ for name, param in sig.parameters.items():
+ if name == "file" or name == "self":
+ continue
+ info = {"name": name}
+ if name in hints:
+ info["type"] = _format_type_hint(hints[name])
+ if param.default is not inspect.Parameter.empty:
+ info["default"] = _serialize_default(param.default)
+ else:
+ info["required"] = True
+ params.append(info)
+ return params
+
+
+def _format_type_hint(hint) -> str | None:
+ """Format a type hint to a readable string."""
+ import ifcopenshell
+
+ if hint is None:
+ return None
+ if hint is type(None):
+ return "None"
+ # ifcopenshell.file params are passed as a file path string
+ if hint is ifcopenshell.file:
+ return "file_path"
+ origin = typing.get_origin(hint)
+ args = typing.get_args(hint)
+
+ # Union (including Optional)
+ if origin is typing.Union:
+ formatted = [_format_type_hint(a) for a in args]
+ # Optional[X] is Union[X, None] — render as "Optional[X]"
+ if len(formatted) == 2 and "None" in formatted:
+ inner = next(f for f in formatted if f != "None")
+ return f"Optional[{inner}]"
+ return " | ".join(formatted)
+
+ # Literal
+ if origin is typing.Literal:
+ values = ", ".join(repr(a) for a in args)
+ return f"Literal[{values}]"
+
+ # Generic types (list, dict, etc.)
+ if origin is not None:
+ origin_name = getattr(origin, "__name__", str(origin))
+ if args:
+ inner = ", ".join(_format_type_hint(a) for a in args)
+ return f"{origin_name}[{inner}]"
+ return origin_name
+
+ # Simple types
+ return getattr(hint, "__name__", str(hint))
+
+
+def _serialize_default(value):
+ """Serialize a default value to something JSON-friendly."""
+ if value is None:
+ return None
+ if isinstance(value, (str, int, float, bool)):
+ return value
+ return repr(value)
+
+
+def _parse_docstring_body(docstring: str) -> tuple[str, str]:
+ """Parse the summary and long description from a docstring."""
+ lines = docstring.strip().split("\n")
+ summary = lines[0].strip() if lines else ""
+ body_lines = []
+ in_body = False
+ for line in lines[1:]:
+ stripped = line.strip()
+ if stripped.startswith(":param") or stripped.startswith(":return"):
+ break
+ if stripped.startswith("Example"):
+ break
+ if not in_body and not stripped:
+ in_body = True
+ continue
+ if in_body:
+ body_lines.append(stripped)
+
+ long_description = " ".join(body_lines).strip()
+ # collapse multiple spaces
+ long_description = re.sub(r"\s+", " ", long_description)
+ return summary, long_description
+
+
+_FIELD_MARKER = re.compile(r":(?:param|returns?|rtype|type|raises?)\b")
+
+
+def _parse_param_docs(docstring: str) -> dict[str, str]:
+ """Extract :param name: description lines from a docstring."""
+ params = {}
+ current_param = None
+ current_lines = []
+ for line in docstring.split("\n"):
+ stripped = line.strip()
+ match = re.match(r":param\s+(\w+):\s*(.*)", stripped)
+ if match:
+ if current_param:
+ params[current_param] = " ".join(current_lines).strip()
+ current_param = match.group(1)
+ current_lines = [match.group(2)]
+ elif current_param and stripped and not _FIELD_MARKER.match(stripped):
+ current_lines.append(stripped)
+ elif _FIELD_MARKER.match(stripped) or (stripped == "" and current_param):
+ if current_param:
+ params[current_param] = " ".join(current_lines).strip()
+ current_param = None
+ current_lines = []
+ if current_param:
+ params[current_param] = " ".join(current_lines).strip()
+ # collapse whitespace
+ return {k: re.sub(r"\s+", " ", v) for k, v in params.items()}
+
+
+def _parse_return_doc(docstring: str) -> str:
+ """Extract :return: description from a docstring."""
+ lines = []
+ in_return = False
+ for line in docstring.split("\n"):
+ stripped = line.strip()
+ match = re.match(r":return:\s*(.*)", stripped)
+ if match:
+ in_return = True
+ lines = [match.group(1)]
+ elif in_return:
+ if _FIELD_MARKER.match(stripped) or stripped == "":
+ break
+ lines.append(stripped)
+ return re.sub(r"\s+", " ", " ".join(lines).strip())
diff --git a/src/ifcedit/ifcedit/foreach.py b/src/ifcedit/ifcedit/foreach.py
new file mode 100644
index 0000000000..587d611c3f
--- /dev/null
+++ b/src/ifcedit/ifcedit/foreach.py
@@ -0,0 +1,76 @@
+# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
+# Copyright (C) 2026 Bruno Postle
+#
+# This file is part of IfcEdit.
+#
+# IfcEdit is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# IfcEdit is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with IfcEdit. If not, see .
+
+from __future__ import annotations
+
+import ifcopenshell
+
+from ifcedit.run import run_api
+
+
+def _substitute(template: str, item: dict) -> str:
+ """Replace {key} placeholders in template with values from item."""
+ for key, value in item.items():
+ template = template.replace(f"{{{key}}}", str(value))
+ return template
+
+
+def run_foreach(
+ model: ifcopenshell.file,
+ module: str,
+ function: str,
+ raw_kwargs_template: dict[str, str],
+ items: list[dict],
+) -> dict:
+ """Apply an API function to each item in a list, substituting {field} placeholders.
+
+ Opens the model once, applies the mutation for every item, and returns a summary.
+ The caller is responsible for saving the model.
+
+ Args:
+ model: The open IFC model (mutated in place).
+ module: API module name (e.g. "root").
+ function: Function name (e.g. "remove_product").
+ raw_kwargs_template: Arg templates with {field} placeholders, e.g. {"product": "{id}"}.
+ items: List of dicts (e.g. from ifcquery select output).
+
+ Returns:
+ {"ok": True, "count": N, "errors": []} on full success,
+ {"ok": False, "count": N, "errors": [{...}]} if any item failed.
+ """
+ errors = []
+ count = 0
+
+ for i, item in enumerate(items):
+ if not isinstance(item, dict):
+ errors.append({"index": i, "item": item, "error": "item is not a dict"})
+ continue
+
+ substituted = {k: _substitute(v, item) for k, v in raw_kwargs_template.items()}
+ result = run_api(model, module, function, substituted)
+
+ if result["ok"]:
+ count += 1
+ else:
+ errors.append({"index": i, "item": item, "error": result["error"]})
+
+ return {
+ "ok": len(errors) == 0,
+ "count": count,
+ "errors": errors,
+ }
diff --git a/src/ifcedit/ifcedit/quantify.py b/src/ifcedit/ifcedit/quantify.py
new file mode 100644
index 0000000000..f85475e22c
--- /dev/null
+++ b/src/ifcedit/ifcedit/quantify.py
@@ -0,0 +1,37 @@
+# This file was generated with the assistance of an AI coding tool.
+from __future__ import annotations
+
+from typing import Any
+
+import ifcopenshell
+
+AVAILABLE_RULES = ["IFC4QtoBaseQuantities", "IFC4X3QtoBaseQuantities"]
+
+
+def list_rules() -> list[dict[str, str]]:
+ """Return a list of available quantification rule names."""
+ return [{"name": name} for name in AVAILABLE_RULES]
+
+
+def run_quantify(model: ifcopenshell.file, rule: str, selector: str | None = None) -> dict[str, Any]:
+ """Run quantity take-off on the model using the named rule.
+
+ Modifies the model in-place by adding/updating IfcElementQuantity psets.
+ Returns a summary dict with ok, rule, and elements_quantified.
+ """
+ from ifc5d.qto import edit_qtos, quantify
+ from ifc5d.qto import rules as rule_sets
+
+ if rule not in rule_sets:
+ return {"ok": False, "error": f"Unknown rule: {rule}. Available: {list(rule_sets.keys())}"}
+
+ import ifcopenshell.util.selector
+
+ if selector:
+ elements = set(ifcopenshell.util.selector.filter_elements(model, selector))
+ else:
+ elements = set(model.by_type("IfcElement"))
+
+ results = quantify(model, elements, rule_sets[rule])
+ edit_qtos(model, results)
+ return {"ok": True, "rule": rule, "elements_quantified": len(results)}
diff --git a/src/ifcedit/ifcedit/run.py b/src/ifcedit/ifcedit/run.py
new file mode 100644
index 0000000000..91d8be6774
--- /dev/null
+++ b/src/ifcedit/ifcedit/run.py
@@ -0,0 +1,148 @@
+# This file was generated with the assistance of an AI coding tool.
+# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
+# Copyright (C) 2026 Bruno Postle
+#
+# This file is part of IfcEdit.
+#
+# IfcEdit is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# IfcEdit is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with IfcEdit. If not, see .
+
+from __future__ import annotations
+
+import importlib
+import inspect
+import typing
+
+import ifcopenshell
+
+from ifcedit.coerce import coerce_value
+
+
+def _is_file_type(hint) -> bool:
+ """Check if a type hint refers to ifcopenshell.file (or Optional[ifcopenshell.file])."""
+ if hint is ifcopenshell.file:
+ return True
+ origin = typing.get_origin(hint)
+ args = typing.get_args(hint)
+ if origin is typing.Union and ifcopenshell.file in args:
+ return True
+ return False
+
+
+def run_api(
+ model: ifcopenshell.file,
+ module: str,
+ function: str,
+ raw_kwargs: dict[str, str],
+) -> dict:
+ """Execute an ifcopenshell.api function with CLI-provided string arguments.
+
+ Args:
+ model: The open IFC model.
+ module: API module name (e.g. "root").
+ function: Function name (e.g. "create_entity").
+ raw_kwargs: String keyword arguments from the CLI.
+
+ Returns:
+ A dict with {"ok": True, "result": ...} on success,
+ or {"ok": False, "error": "..."} on failure.
+ """
+ try:
+ fn = _import_function(module, function)
+ except (ImportError, AttributeError) as e:
+ return {"ok": False, "error": f"Cannot find function '{module}.{function}': {e}"}
+
+ try:
+ hints = typing.get_type_hints(fn)
+ except Exception:
+ hints = {}
+
+ sig = inspect.signature(fn)
+ coerced_kwargs = {}
+
+ # Pass 1: coerce ifcopenshell.file-typed params first (e.g. library= in append_asset).
+ # The opened file is then used as the lookup file for entity resolution in pass 2.
+ opened_files: list[ifcopenshell.file] = []
+ for name, value_str in raw_kwargs.items():
+ if name not in sig.parameters:
+ return {"ok": False, "error": f"Unknown parameter '{name}' for {module}.{function}"}
+ hint = hints.get(name)
+ if not _is_file_type(hint):
+ continue
+ try:
+ coerced = coerce_value(value_str, hint, model)
+ coerced_kwargs[name] = coerced
+ if isinstance(coerced, ifcopenshell.file):
+ opened_files.append(coerced)
+ except (ValueError, TypeError) as e:
+ return {"ok": False, "error": f"Cannot convert parameter '{name}': {e}"}
+
+ # Pass 2: coerce remaining params. Entity instance IDs are resolved from the opened
+ # library file (if any), since you are always appending from another file, never
+ # from the current model.
+ lookup_file = opened_files[0] if opened_files else None
+ for name, value_str in raw_kwargs.items():
+ if name in coerced_kwargs:
+ continue
+ if name not in sig.parameters:
+ return {"ok": False, "error": f"Unknown parameter '{name}' for {module}.{function}"}
+ hint = hints.get(name)
+ try:
+ coerced_kwargs[name] = coerce_value(value_str, hint, model, lookup_file=lookup_file)
+ except (ValueError, TypeError) as e:
+ return {"ok": False, "error": f"Cannot convert parameter '{name}': {e}"}
+
+ # Determine if the function takes 'file' as its first parameter
+ first_param = next(iter(sig.parameters), None)
+ try:
+ if first_param == "file":
+ result = fn(model, **coerced_kwargs)
+ else:
+ result = fn(**coerced_kwargs)
+ except Exception as e:
+ return {"ok": False, "error": f"{type(e).__name__}: {e}"}
+
+ return {"ok": True, "result": serialize_result(result)}
+
+
+def _import_function(module: str, function: str):
+ """Import and return the underlying function from ifcopenshell.api."""
+ fn_module = importlib.import_module(f"ifcopenshell.api.{module}.{function}")
+ fn = getattr(fn_module, function)
+ return fn
+
+
+def serialize_result(value) -> object:
+ """Serialize an API result to a JSON-friendly structure."""
+ if value is None:
+ return None
+ if isinstance(value, ifcopenshell.entity_instance):
+ return _serialize_entity(value)
+ if isinstance(value, (list, tuple, set, frozenset)):
+ return [serialize_result(item) for item in value]
+ if isinstance(value, dict):
+ return {str(k): serialize_result(v) for k, v in value.items()}
+ if isinstance(value, (str, int, float, bool)):
+ return value
+ return str(value)
+
+
+def _serialize_entity(entity: ifcopenshell.entity_instance) -> dict:
+ """Serialize an entity instance to a summary dict."""
+ result = {
+ "id": entity.id(),
+ "type": entity.is_a(),
+ }
+ if hasattr(entity, "Name") and entity.Name:
+ result["name"] = entity.Name
+ return result
diff --git a/src/ifcedit/pyproject.toml b/src/ifcedit/pyproject.toml
new file mode 100644
index 0000000000..f50c0b3cc4
--- /dev/null
+++ b/src/ifcedit/pyproject.toml
@@ -0,0 +1,33 @@
+[build-system]
+requires = ["setuptools>=61.0"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "ifcedit"
+version = "0.0.0"
+authors = [
+ { name="Bruno Postle", email="bruno@postle.net" },
+]
+description = "CLI wrapper for ifcopenshell.api IFC model mutation functions"
+readme = "README.md"
+keywords = ["IFC", "BIM", "API"]
+classifiers = [
+ "Programming Language :: Python :: 3",
+ "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
+]
+dependencies = ["ifcopenshell", "ifc5d"]
+
+[project.scripts]
+ifcedit = "ifcedit.__main__:main"
+
+[project.urls]
+Homepage = "http://ifcopenshell.org"
+Documentation = "https://docs.ifcopenshell.org"
+Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues"
+
+[tool.setuptools.packages.find]
+include = ["ifcedit*"]
+exclude = ["test*"]
+
+[tool.ruff]
+extend = "../../pyproject.toml"
diff --git a/src/ifcedit/tests/__init__.py b/src/ifcedit/tests/__init__.py
new file mode 100644
index 0000000000..0a3bc271a0
--- /dev/null
+++ b/src/ifcedit/tests/__init__.py
@@ -0,0 +1 @@
+# This file was generated with the assistance of an AI coding tool.
diff --git a/src/ifcedit/tests/conftest.py b/src/ifcedit/tests/conftest.py
new file mode 100644
index 0000000000..241220b145
--- /dev/null
+++ b/src/ifcedit/tests/conftest.py
@@ -0,0 +1,63 @@
+# This file was generated with the assistance of an AI coding tool.
+import ifcopenshell
+import ifcopenshell.api.aggregate
+import ifcopenshell.api.material
+import ifcopenshell.api.owner.settings
+import ifcopenshell.api.project
+import ifcopenshell.api.pset
+import ifcopenshell.api.root
+import ifcopenshell.api.spatial
+import ifcopenshell.api.unit
+import pytest
+
+
+@pytest.fixture
+def model():
+ """Create an IFC4 model with a spatial hierarchy and a wall."""
+ f = ifcopenshell.api.project.create_file()
+ ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
+ ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
+
+ project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject")
+ ifcopenshell.api.unit.assign_unit(f)
+
+ site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite")
+ building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding")
+ storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor")
+
+ ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
+ ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
+ ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
+
+ wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
+ ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
+
+ return f
+
+
+@pytest.fixture
+def model_file(model, tmp_path):
+ """Write the model fixture to a temp file and return the path."""
+ path = tmp_path / "test.ifc"
+ model.write(str(path))
+ return str(path)
+
+
+@pytest.fixture
+def library():
+ """Create an IFC4 library with a single IfcWallType asset."""
+ lib = ifcopenshell.api.project.create_file()
+ ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
+ ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
+ ifcopenshell.api.root.create_entity(lib, ifc_class="IfcProject", name="TestLibrary")
+ ifcopenshell.api.unit.assign_unit(lib)
+ ifcopenshell.api.root.create_entity(lib, ifc_class="IfcWallType", name="WAL01")
+ return lib
+
+
+@pytest.fixture
+def library_file(library, tmp_path):
+ """Write the library fixture to a temp file and return the path."""
+ path = tmp_path / "library.ifc"
+ library.write(str(path))
+ return str(path)
diff --git a/src/ifcedit/tests/test_coerce.py b/src/ifcedit/tests/test_coerce.py
new file mode 100644
index 0000000000..4ee6895717
--- /dev/null
+++ b/src/ifcedit/tests/test_coerce.py
@@ -0,0 +1,158 @@
+# This file was generated with the assistance of an AI coding tool.
+import json
+from typing import Literal, Optional, Union
+
+import ifcopenshell
+import ifcopenshell.api.project
+import pytest
+
+from ifcedit.coerce import coerce_value
+
+
+class TestStringCoercion:
+ def test_plain_string(self):
+ assert coerce_value("hello", str) == "hello"
+
+ def test_empty_string(self):
+ assert coerce_value("", str) == ""
+
+
+class TestIntCoercion:
+ def test_plain_int(self):
+ assert coerce_value("42", int) == 42
+
+ def test_hash_prefix(self):
+ assert coerce_value("#42", int) == 42
+
+ def test_negative(self):
+ assert coerce_value("-5", int) == -5
+
+
+class TestFloatCoercion:
+ def test_plain_float(self):
+ assert coerce_value("3.14", float) == pytest.approx(3.14)
+
+ def test_integer_as_float(self):
+ assert coerce_value("5", float) == 5.0
+
+
+class TestBoolCoercion:
+ def test_true_values(self):
+ for val in ("true", "True", "TRUE", "1", "yes"):
+ assert coerce_value(val, bool) is True
+
+ def test_false_values(self):
+ for val in ("false", "False", "0", "no"):
+ assert coerce_value(val, bool) is False
+
+
+class TestOptionalCoercion:
+ def test_optional_string(self):
+ assert coerce_value("hello", Optional[str]) == "hello"
+
+ def test_optional_none(self):
+ assert coerce_value("none", Optional[str]) is None
+ assert coerce_value("None", Optional[str]) is None
+
+ def test_optional_int(self):
+ assert coerce_value("42", Optional[int]) == 42
+
+
+class TestUnionCoercion:
+ def test_union_str_int(self):
+ # Tries str first (or int first depending on order), both work
+ result = coerce_value("hello", Union[str, int])
+ assert result == "hello"
+
+ def test_union_int_none(self):
+ result = coerce_value("42", Union[int, None])
+ assert result == 42
+
+
+class TestLiteralCoercion:
+ def test_valid_literal(self):
+ assert coerce_value("IFC4", Literal["IFC2X3", "IFC4", "IFC4X3"]) == "IFC4"
+
+ def test_invalid_literal(self):
+ with pytest.raises(ValueError, match="not one of"):
+ coerce_value("IFC5", Literal["IFC2X3", "IFC4", "IFC4X3"])
+
+
+class TestDictCoercion:
+ def test_json_dict(self):
+ result = coerce_value('{"IsExternal": true, "FireRating": "2HR"}', dict[str, object])
+ assert result == {"IsExternal": True, "FireRating": "2HR"}
+
+ def test_mixed_float_int_list_coerced_to_float(self):
+ # [0.419, 0, 0.908] — JSON integer 0 mixed with floats must become float
+ # so ifcopenshell AGGREGATE OF DOUBLE attributes (e.g. DirectionRatios) don't reject the list
+ result = coerce_value('{"DirectionRatios": [0.419, 0, 0.908]}', dict[str, object])
+ assert result["DirectionRatios"] == pytest.approx([0.419, 0.0, 0.908])
+ assert all(isinstance(v, float) for v in result["DirectionRatios"])
+
+ def test_pure_int_list_not_coerced(self):
+ # All-integer lists (e.g. face indices) must stay as ints
+ result = coerce_value('{"CoordIndex": [0, 1, 2]}', dict[str, object])
+ assert result["CoordIndex"] == [0, 1, 2]
+ assert all(isinstance(v, int) for v in result["CoordIndex"])
+
+
+class TestListCoercion:
+ def test_comma_separated(self):
+ result = coerce_value("a,b,c", list[str])
+ assert result == ["a", "b", "c"]
+
+ def test_json_array(self):
+ result = coerce_value("[1, 2, 3]", list[int])
+ assert result == [1, 2, 3]
+
+
+class TestEntityCoercion:
+ def test_entity_by_id(self, model):
+ wall = model.by_type("IfcWall")[0]
+ result = coerce_value(str(wall.id()), ifcopenshell.entity_instance, model)
+ assert result == wall
+
+ def test_entity_with_hash(self, model):
+ wall = model.by_type("IfcWall")[0]
+ result = coerce_value(f"#{wall.id()}", ifcopenshell.entity_instance, model)
+ assert result == wall
+
+ def test_entity_not_found(self, model):
+ with pytest.raises(ValueError, match="not found"):
+ coerce_value("999999", ifcopenshell.entity_instance, model)
+
+ def test_entity_list(self, model):
+ wall = model.by_type("IfcWall")[0]
+ result = coerce_value(str(wall.id()), list[ifcopenshell.entity_instance], model)
+ assert len(result) == 1
+ assert result[0] == wall
+
+ def test_entity_list_multiple(self, model):
+ wall = model.by_type("IfcWall")[0]
+ storey = model.by_type("IfcBuildingStorey")[0]
+ result = coerce_value(f"{wall.id()},{storey.id()}", list[ifcopenshell.entity_instance], model)
+ assert len(result) == 2
+
+ def test_entity_no_model(self):
+ with pytest.raises(ValueError, match="without an IFC model"):
+ coerce_value("42", ifcopenshell.entity_instance, None)
+
+
+class TestFileCoercion:
+ def test_opens_file_from_path(self, model_file):
+ result = coerce_value(model_file, ifcopenshell.file)
+ assert isinstance(result, ifcopenshell.file)
+
+ def test_entity_from_lookup_file(self, model_file):
+ lib = ifcopenshell.open(model_file)
+ wall = lib.by_type("IfcWall")[0]
+ empty_model = ifcopenshell.api.project.create_file()
+ result = coerce_value(str(wall.id()), ifcopenshell.entity_instance, empty_model, lookup_file=lib)
+ assert result.id() == wall.id()
+ assert result.is_a("IfcWall")
+
+
+class TestFallback:
+ def test_no_type_hint(self):
+ assert coerce_value("hello", None) == "hello"
diff --git a/src/ifcedit/tests/test_discover.py b/src/ifcedit/tests/test_discover.py
new file mode 100644
index 0000000000..7b77cb5b7e
--- /dev/null
+++ b/src/ifcedit/tests/test_discover.py
@@ -0,0 +1,104 @@
+# This file was generated with the assistance of an AI coding tool.
+from ifcedit.discover import function_docs, list_functions, list_modules
+
+
+class TestListModules:
+ def test_returns_list(self):
+ result = list_modules()
+ assert isinstance(result, list)
+ assert len(result) > 0
+
+ def test_module_structure(self):
+ result = list_modules()
+ for entry in result:
+ assert "module" in entry
+ assert "description" in entry
+ assert "functions" in entry
+ assert "count" in entry
+ assert isinstance(entry["functions"], list)
+ assert entry["count"] == len(entry["functions"])
+
+ def test_known_modules_present(self):
+ result = list_modules()
+ module_names = [m["module"] for m in result]
+ for expected in ("root", "spatial", "pset", "aggregate", "unit"):
+ assert expected in module_names
+
+ def test_root_module_has_functions(self):
+ result = list_modules()
+ root = next(m for m in result if m["module"] == "root")
+ assert "create_entity" in root["functions"]
+ assert root["count"] >= 3
+
+
+class TestListFunctions:
+ def test_root_functions(self):
+ result = list_functions("root")
+ assert isinstance(result, list)
+ names = [f["name"] for f in result]
+ assert "create_entity" in names
+
+ def test_function_structure(self):
+ result = list_functions("root")
+ for fn in result:
+ assert "name" in fn
+ assert "description" in fn
+ assert "params" in fn
+
+ def test_create_entity_params(self):
+ result = list_functions("root")
+ create = next(f for f in result if f["name"] == "create_entity")
+ param_names = [p["name"] for p in create["params"]]
+ assert "ifc_class" in param_names
+ assert "name" in param_names
+
+ def test_pset_functions(self):
+ result = list_functions("pset")
+ names = [f["name"] for f in result]
+ assert "add_pset" in names
+ assert "edit_pset" in names
+
+
+class TestFunctionDocs:
+ def test_create_entity_docs(self):
+ result = function_docs("root", "create_entity")
+ assert result["module"] == "root"
+ assert result["function"] == "create_entity"
+ assert result["description"]
+ assert isinstance(result["params"], list)
+ assert len(result["params"]) > 0
+
+ def test_params_have_types(self):
+ result = function_docs("root", "create_entity")
+ for param in result["params"]:
+ assert "name" in param
+ assert "type" in param
+
+ def test_params_have_descriptions(self):
+ result = function_docs("root", "create_entity")
+ ifc_class = next(p for p in result["params"] if p["name"] == "ifc_class")
+ assert "description" in ifc_class
+ assert len(ifc_class["description"]) > 0
+
+ def test_return_type(self):
+ result = function_docs("root", "create_entity")
+ assert "return_type" in result
+
+ def test_assign_container_docs(self):
+ result = function_docs("spatial", "assign_container")
+ assert result["module"] == "spatial"
+ param_names = [p["name"] for p in result["params"]]
+ assert "products" in param_names
+ assert "relating_structure" in param_names
+
+ def test_unknown_function_raises(self):
+ import pytest
+
+ with pytest.raises(ValueError, match="not found"):
+ function_docs("root", "nonexistent_function")
+
+ def test_edit_pset_docs(self):
+ result = function_docs("pset", "edit_pset")
+ param_names = [p["name"] for p in result["params"]]
+ assert "pset" in param_names
+ assert "properties" in param_names
diff --git a/src/ifcedit/tests/test_foreach.py b/src/ifcedit/tests/test_foreach.py
new file mode 100644
index 0000000000..d464fedcf3
--- /dev/null
+++ b/src/ifcedit/tests/test_foreach.py
@@ -0,0 +1,85 @@
+# Tests for ifcedit.foreach
+import ifcopenshell
+import ifcopenshell.api.owner.settings
+import ifcopenshell.api.project
+import ifcopenshell.api.root
+import ifcopenshell.api.spatial
+import ifcopenshell.api.unit
+import pytest
+
+from ifcedit.foreach import _substitute, run_foreach
+
+
+@pytest.fixture
+def model(model):
+ return model
+
+
+class TestSubstitute:
+ def test_single_field(self):
+ assert _substitute("--product {id}", {"id": 42}) == "--product 42"
+
+ def test_multiple_fields(self):
+ result = _substitute("{type} #{id} ({name})", {"id": 5, "type": "IfcWall", "name": "W1"})
+ assert result == "IfcWall #5 (W1)"
+
+ def test_no_placeholder(self):
+ assert _substitute("hello", {"id": 1}) == "hello"
+
+ def test_unknown_placeholder_unchanged(self):
+ assert _substitute("{unknown}", {"id": 1}) == "{unknown}"
+
+
+class TestRunForeach:
+ def _items(self, model, ifc_class):
+ return [{"id": e.id(), "type": e.is_a(), "name": e.Name} for e in model.by_type(ifc_class)]
+
+ def test_rename_single(self, model):
+ items = self._items(model, "IfcWall")
+ result = run_foreach(
+ model, "attribute", "edit_attributes", {"product": "{id}", "attributes": '{"Name": "R"}'}, items
+ )
+ assert result["ok"] is True
+ assert result["count"] == 1
+ assert result["errors"] == []
+ assert model.by_type("IfcWall")[0].Name == "R"
+
+ def test_rename_multiple(self, model):
+ items = self._items(model, "IfcElement")
+ result = run_foreach(
+ model, "attribute", "edit_attributes", {"product": "{id}", "attributes": '{"Name": "X"}'}, items
+ )
+ assert result["ok"] is True
+ assert result["count"] == len(items)
+
+ def test_empty_list(self, model):
+ result = run_foreach(model, "root", "remove_product", {"product": "{id}"}, [])
+ assert result["ok"] is True
+ assert result["count"] == 0
+ assert result["errors"] == []
+
+ def test_bad_id_collects_error(self, model):
+ items = [{"id": 999999, "type": "IfcWall", "name": "X"}]
+ result = run_foreach(model, "root", "remove_product", {"product": "{id}"}, items)
+ assert result["ok"] is False
+ assert result["count"] == 0
+ assert len(result["errors"]) == 1
+ assert result["errors"][0]["index"] == 0
+
+ def test_non_dict_item_collects_error(self, model):
+ result = run_foreach(model, "root", "remove_product", {"product": "{id}"}, ["not_a_dict"])
+ assert result["ok"] is False
+ assert len(result["errors"]) == 1
+
+ def test_partial_failure_counts_successes(self, model):
+ wall_id = model.by_type("IfcWall")[0].id()
+ items = [
+ {"id": wall_id, "type": "IfcWall", "name": "W"},
+ {"id": 999999, "type": "IfcWall", "name": "Bad"},
+ ]
+ result = run_foreach(
+ model, "attribute", "edit_attributes", {"product": "{id}", "attributes": '{"Name": "Ok"}'}, items
+ )
+ assert result["ok"] is False
+ assert result["count"] == 1
+ assert len(result["errors"]) == 1
diff --git a/src/ifcedit/tests/test_main.py b/src/ifcedit/tests/test_main.py
new file mode 100644
index 0000000000..00585d5172
--- /dev/null
+++ b/src/ifcedit/tests/test_main.py
@@ -0,0 +1,211 @@
+# This file was generated with the assistance of an AI coding tool.
+import json
+import subprocess
+import sys
+
+import ifcopenshell
+import pytest
+
+
+def run_ifcedit(*args, stdin=None):
+ """Run ifcedit as a subprocess and return (stdout, stderr, returncode)."""
+ result = subprocess.run(
+ [sys.executable, "-m", "ifcedit", *args],
+ capture_output=True,
+ text=True,
+ input=stdin,
+ )
+ return result.stdout, result.stderr, result.returncode
+
+
+class TestListCommand:
+ def test_list_all_modules(self):
+ stdout, stderr, rc = run_ifcedit("list")
+ assert rc == 0
+ data = json.loads(stdout)
+ assert isinstance(data, list)
+ module_names = [m["module"] for m in data]
+ assert "root" in module_names
+ assert "spatial" in module_names
+
+ def test_list_module_functions(self):
+ stdout, stderr, rc = run_ifcedit("list", "root")
+ assert rc == 0
+ data = json.loads(stdout)
+ assert isinstance(data, list)
+ names = [f["name"] for f in data]
+ assert "create_entity" in names
+
+ def test_list_text_format(self):
+ stdout, stderr, rc = run_ifcedit("--format", "text", "list")
+ assert rc == 0
+ assert "root" in stdout
+
+
+class TestDocsCommand:
+ def test_docs_create_entity(self):
+ stdout, stderr, rc = run_ifcedit("docs", "root.create_entity")
+ assert rc == 0
+ data = json.loads(stdout)
+ assert data["module"] == "root"
+ assert data["function"] == "create_entity"
+ assert "params" in data
+
+ def test_docs_invalid_path(self):
+ stdout, stderr, rc = run_ifcedit("docs", "invalid_path")
+ assert rc != 0
+ assert "module.function" in stderr
+
+ def test_docs_unknown_function(self):
+ stdout, stderr, rc = run_ifcedit("docs", "root.nonexistent")
+ assert rc != 0
+
+
+class TestRunCommand:
+ def test_create_entity(self, model_file):
+ stdout, stderr, rc = run_ifcedit(
+ "run", model_file, "root.create_entity", "--ifc_class", "IfcWall", "--name", "CLIWall"
+ )
+ assert rc == 0, f"stderr: {stderr}"
+ data = json.loads(stdout)
+ assert data["ok"] is True
+ assert data["result"]["type"] == "IfcWall"
+ assert data["result"]["name"] == "CLIWall"
+
+ def test_dry_run(self, model_file):
+ stdout, stderr, rc = run_ifcedit("run", model_file, "root.create_entity", "--dry-run", "--ifc_class", "IfcWall")
+ assert rc == 0
+ data = json.loads(stdout)
+ assert data["ok"] is True
+ assert data["dry_run"] is True
+
+ def test_output_to_different_file(self, model_file, tmp_path):
+ output = str(tmp_path / "output.ifc")
+ stdout, stderr, rc = run_ifcedit(
+ "run", model_file, "root.create_entity", "-o", output, "--ifc_class", "IfcSlab"
+ )
+ assert rc == 0, f"stderr: {stderr}"
+ data = json.loads(stdout)
+ assert data["ok"] is True
+
+ import os
+
+ assert os.path.exists(output)
+
+ def test_run_error_bad_function(self, model_file):
+ stdout, stderr, rc = run_ifcedit("run", model_file, "root.nonexistent")
+ assert rc != 0
+
+ def test_run_invalid_function_path(self, model_file):
+ stdout, stderr, rc = run_ifcedit("run", model_file, "invalid_path")
+ assert rc != 0
+ assert "module.function" in stderr
+
+
+class TestForeachCommand:
+ def _select_json(self, model, ifc_class):
+ """Build a JSON array like ifcquery select would produce."""
+ elements = model.by_type(ifc_class)
+ return json.dumps([{"id": e.id(), "type": e.is_a(), "name": getattr(e, "Name", None)} for e in elements])
+
+ def test_foreach_rename(self, model, model_file):
+ walls_json = self._select_json(model, "IfcWall")
+ stdout, stderr, rc = run_ifcedit(
+ "foreach",
+ model_file,
+ "attribute.edit_attributes",
+ "--product",
+ "{id}",
+ "--attributes",
+ '{"Name": "Renamed"}',
+ stdin=walls_json,
+ )
+ assert rc == 0, f"stderr: {stderr}"
+ data = json.loads(stdout)
+ assert data["ok"] is True
+ assert data["count"] == 1
+ assert data["errors"] == []
+ updated = ifcopenshell.open(model_file)
+ assert updated.by_type("IfcWall")[0].Name == "Renamed"
+
+ def test_foreach_multiple_elements(self, model, model_file):
+ # Build a two-item list by selecting all IfcObject (includes spatial structure + elements)
+ elements_json = self._select_json(model, "IfcObject")
+ items = json.loads(elements_json)
+ assert len(items) >= 2
+ stdout, stderr, rc = run_ifcedit(
+ "foreach",
+ model_file,
+ "attribute.edit_attributes",
+ "--product",
+ "{id}",
+ "--attributes",
+ '{"Name": "Bulk"}',
+ stdin=elements_json,
+ )
+ assert rc == 0, f"stderr: {stderr}"
+ data = json.loads(stdout)
+ assert data["ok"] is True
+ assert data["count"] == len(items)
+
+ def test_foreach_empty_list(self, model_file):
+ stdout, stderr, rc = run_ifcedit(
+ "foreach",
+ model_file,
+ "attribute.edit_attributes",
+ "--product",
+ "{id}",
+ "--attributes",
+ '{"Name": "X"}',
+ stdin="[]",
+ )
+ assert rc == 0
+ data = json.loads(stdout)
+ assert data["ok"] is True
+ assert data["count"] == 0
+
+ def test_foreach_invalid_json_stdin(self, model_file):
+ stdout, stderr, rc = run_ifcedit(
+ "foreach",
+ model_file,
+ "root.remove_product",
+ "--product",
+ "{id}",
+ stdin="not json",
+ )
+ assert rc != 0
+ assert "Error" in stderr
+
+ def test_foreach_not_array_stdin(self, model_file):
+ stdout, stderr, rc = run_ifcedit(
+ "foreach",
+ model_file,
+ "root.remove_product",
+ "--product",
+ "{id}",
+ stdin='{"id": 1}',
+ )
+ assert rc != 0
+ assert "Error" in stderr
+
+ def test_foreach_output_to_different_file(self, model, model_file, tmp_path):
+ import os
+
+ output = str(tmp_path / "out.ifc")
+ walls_json = self._select_json(model, "IfcWall")
+ stdout, stderr, rc = run_ifcedit(
+ "foreach",
+ model_file,
+ "attribute.edit_attributes",
+ "-o",
+ output,
+ "--product",
+ "{id}",
+ "--attributes",
+ '{"Name": "OutFile"}',
+ stdin=walls_json,
+ )
+ assert rc == 0, f"stderr: {stderr}"
+ assert os.path.exists(output)
+ updated = ifcopenshell.open(output)
+ assert updated.by_type("IfcWall")[0].Name == "OutFile"
diff --git a/src/ifcedit/tests/test_quantify.py b/src/ifcedit/tests/test_quantify.py
new file mode 100644
index 0000000000..f4335fb2a7
--- /dev/null
+++ b/src/ifcedit/tests/test_quantify.py
@@ -0,0 +1,87 @@
+# This file was generated with the assistance of an AI coding tool.
+from __future__ import annotations
+
+import ifcopenshell
+import ifcopenshell.api.aggregate
+import ifcopenshell.api.owner.settings
+import ifcopenshell.api.project
+import ifcopenshell.api.root
+import ifcopenshell.api.spatial
+import ifcopenshell.api.unit
+import pytest
+
+from ifcedit.quantify import AVAILABLE_RULES, list_rules, run_quantify
+
+
+class TestListRules:
+ def test_returns_list(self):
+ result = list_rules()
+ assert isinstance(result, list)
+
+ def test_each_entry_has_name(self):
+ result = list_rules()
+ for entry in result:
+ assert "name" in entry
+
+ def test_ifc4_rule_present(self):
+ result = list_rules()
+ names = [r["name"] for r in result]
+ assert "IFC4QtoBaseQuantities" in names
+
+ def test_ifc4x3_rule_present(self):
+ result = list_rules()
+ names = [r["name"] for r in result]
+ assert "IFC4X3QtoBaseQuantities" in names
+
+
+@pytest.fixture
+def quantify_model():
+ """Create an IFC4 model with a wall element."""
+ f = ifcopenshell.api.project.create_file()
+ ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
+ ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
+
+ project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject")
+ ifcopenshell.api.unit.assign_unit(f)
+
+ site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite")
+ building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding")
+ storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor")
+
+ ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
+ ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
+ ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
+
+ wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
+ ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
+
+ return f
+
+
+class TestRunQuantify:
+ def test_returns_ok_true(self, quantify_model):
+ result = run_quantify(quantify_model, "IFC4QtoBaseQuantities")
+ assert result["ok"] is True
+
+ def test_returns_rule_name(self, quantify_model):
+ result = run_quantify(quantify_model, "IFC4QtoBaseQuantities")
+ assert result["rule"] == "IFC4QtoBaseQuantities"
+
+ def test_returns_elements_quantified(self, quantify_model):
+ result = run_quantify(quantify_model, "IFC4QtoBaseQuantities")
+ assert "elements_quantified" in result
+ assert isinstance(result["elements_quantified"], int)
+
+ def test_unknown_rule_returns_error(self, quantify_model):
+ result = run_quantify(quantify_model, "NonExistentRule")
+ assert result["ok"] is False
+ assert "error" in result
+
+ def test_selector_restricts_elements(self, quantify_model):
+ result = run_quantify(quantify_model, "IFC4QtoBaseQuantities", selector="IfcWall")
+ assert result["ok"] is True
+ assert result["rule"] == "IFC4QtoBaseQuantities"
+
+ def test_empty_selector_runs_on_all(self, quantify_model):
+ result = run_quantify(quantify_model, "IFC4QtoBaseQuantities", selector=None)
+ assert result["ok"] is True
diff --git a/src/ifcedit/tests/test_run.py b/src/ifcedit/tests/test_run.py
new file mode 100644
index 0000000000..80881e879f
--- /dev/null
+++ b/src/ifcedit/tests/test_run.py
@@ -0,0 +1,97 @@
+# This file was generated with the assistance of an AI coding tool.
+import ifcopenshell
+import ifcopenshell.api.project
+import ifcopenshell.api.pset
+import ifcopenshell.api.root
+
+from ifcedit.run import run_api, serialize_result
+
+
+class TestRunApi:
+ def test_create_entity(self, model):
+ result = run_api(model, "root", "create_entity", {"ifc_class": "IfcWall", "name": "NewWall"})
+ assert result["ok"] is True
+ assert result["result"]["type"] == "IfcWall"
+ assert result["result"]["name"] == "NewWall"
+ assert isinstance(result["result"]["id"], int)
+
+ def test_create_entity_default_class(self, model):
+ result = run_api(model, "root", "create_entity", {})
+ assert result["ok"] is True
+ assert result["result"]["type"] == "IfcBuildingElementProxy"
+
+ def test_assign_container(self, model):
+ wall = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall", name="TestWall2")
+ storey = model.by_type("IfcBuildingStorey")[0]
+ result = run_api(
+ model,
+ "spatial",
+ "assign_container",
+ {"products": str(wall.id()), "relating_structure": str(storey.id())},
+ )
+ assert result["ok"] is True
+ assert result["result"]["type"] == "IfcRelContainedInSpatialStructure"
+
+ def test_add_pset(self, model):
+ wall = model.by_type("IfcWall")[0]
+ result = run_api(model, "pset", "add_pset", {"product": str(wall.id()), "name": "Pset_WallCommon"})
+ assert result["ok"] is True
+ assert result["result"]["type"] == "IfcPropertySet"
+
+ def test_unknown_function(self, model):
+ result = run_api(model, "root", "nonexistent", {})
+ assert result["ok"] is False
+ assert "Cannot find" in result["error"]
+
+ def test_unknown_parameter(self, model):
+ result = run_api(model, "root", "create_entity", {"bogus_param": "value"})
+ assert result["ok"] is False
+ assert "Unknown parameter" in result["error"]
+
+ def test_bad_entity_reference(self, model):
+ result = run_api(model, "pset", "add_pset", {"product": "999999", "name": "Pset_WallCommon"})
+ assert result["ok"] is False
+ assert "not found" in result["error"]
+
+
+class TestAppendAsset:
+ def test_append_asset_from_library(self, model, library_file):
+ lib = ifcopenshell.open(library_file)
+ wall_type = lib.by_type("IfcWallType")[0]
+ result = run_api(
+ model,
+ "project",
+ "append_asset",
+ {"library": library_file, "element": str(wall_type.id())},
+ )
+ assert result["ok"] is True
+ assert result["result"]["type"] == "IfcWallType"
+ assert model.by_type("IfcWallType"), "wall type should have been appended to the model"
+
+
+class TestSerializeResult:
+ def test_none(self):
+ assert serialize_result(None) is None
+
+ def test_string(self):
+ assert serialize_result("hello") == "hello"
+
+ def test_int(self):
+ assert serialize_result(42) == 42
+
+ def test_entity(self, model):
+ wall = model.by_type("IfcWall")[0]
+ result = serialize_result(wall)
+ assert result["id"] == wall.id()
+ assert result["type"] == "IfcWall"
+ assert result["name"] == "Wall001"
+
+ def test_list(self, model):
+ walls = model.by_type("IfcWall")
+ result = serialize_result(walls)
+ assert isinstance(result, list)
+ assert all(isinstance(r, dict) for r in result)
+
+ def test_dict(self):
+ result = serialize_result({"key": "value"})
+ assert result == {"key": "value"}
diff --git a/src/ifcfm/ifcfm/cobie24.py b/src/ifcfm/ifcfm/cobie24.py
index f4848e1654..b83a4abe08 100644
--- a/src/ifcfm/ifcfm/cobie24.py
+++ b/src/ifcfm/ifcfm/cobie24.py
@@ -955,7 +955,7 @@ def get_unit_type_name(ifc_file: ifcopenshell.file, unit_type: str) -> Union[str
return val(unit.Currency)
-def get_unit_name(ifc_file: ifcopenshell.entity_instance, unit: ifcopenshell.entity_instance) -> Union[str, None]:
+def get_unit_name(unit: ifcopenshell.entity_instance) -> Union[str, None]:
if unit.is_a("IfcNamedUnit"):
return val(unit.Name)
diff --git a/src/ifcfm/ifcfm/cobie24legacy.py b/src/ifcfm/ifcfm/cobie24legacy.py
index 078529e892..b3aa069e75 100644
--- a/src/ifcfm/ifcfm/cobie24legacy.py
+++ b/src/ifcfm/ifcfm/cobie24legacy.py
@@ -953,7 +953,7 @@ def get_unit_type_name(ifc_file: ifcopenshell.file, unit_type: str) -> Union[str
return val(unit.Currency)
-def get_unit_name(ifc_file: ifcopenshell.entity_instance, unit: ifcopenshell.entity_instance) -> Union[str, None]:
+def get_unit_name(unit: ifcopenshell.entity_instance) -> Union[str, None]:
if unit.is_a("IfcNamedUnit"):
return val(unit.Name)
diff --git a/src/ifcgeom/Iterator.cpp b/src/ifcgeom/Iterator.cpp
index a7f700f4de..4db7db8946 100644
--- a/src/ifcgeom/Iterator.cpp
+++ b/src/ifcgeom/Iterator.cpp
@@ -620,17 +620,7 @@ const IfcGeom::Element* IfcGeom::Iterator::get_object(int id) {
}
} catch (const std::exception& e) {
Logger::Error(e);
- }
-#ifdef IFOPSH_WITH_OPENCASCADE
- catch (const Standard_Failure& e) {
- if (e.GetMessageString() && strlen(e.GetMessageString())) {
- Logger::Error(e.GetMessageString());
- } else {
- Logger::Error("Unknown error returning product");
- }
- }
-#endif
- catch (...) {
+ } catch (...) {
Logger::Error("Unknown error returning product");
}
@@ -645,18 +635,7 @@ const IfcUtil::IfcBaseClass* IfcGeom::Iterator::create() {
} catch (const std::exception& e) {
Logger::Error(e);
had_error_processing_elements_ = true;
- }
-#ifdef IFOPSH_WITH_OPENCASCADE
- catch (const Standard_Failure& e) {
- if (e.GetMessageString() && strlen(e.GetMessageString())) {
- Logger::Error(e.GetMessageString());
- } else {
- Logger::Error("Unknown error creating geometry");
- }
- had_error_processing_elements_ = true;
- }
-#endif
- catch (...) {
+ } catch (...) {
Logger::Error("Unknown error creating geometry");
had_error_processing_elements_ = true;
}
diff --git a/src/ifcgeom/Iterator.h b/src/ifcgeom/Iterator.h
index 3f5f90a350..fc9e542d60 100644
--- a/src/ifcgeom/Iterator.h
+++ b/src/ifcgeom/Iterator.h
@@ -68,10 +68,6 @@
#include "../ifcgeom/abstract_mapping.h"
#include "../ifcgeom/GeometrySerializer.h"
-#ifdef IFOPSH_WITH_OPENCASCADE
-#include
-#endif
-
#include
#include